iCal: the machine readable calendars
written by martin · published on · 5 minutes to read

Every time I work with timezones this joke comes to my mind:

Q: How many days since the last timezone error?
A: -1

But in all seriousness, handling events across multiple timezones, syncing calendars and working with series events require a deep dive into rfc5545. In this document I will do exactly that and lay out all the interesting details and edge cases I came across.

Tldr; All-day events are not bound to a time zone.

Preamble

Before we start the deep dive into what I have found confusing when working with the ical format I wanted to point out that you most likely do not have to implement it your self. There is for example a python library called iCalEvents that will do the heavy lifting for you. Or you can directly access calendar events in a more intuitive way via the APIs provided from Microsoft and Google.

A few years ago I had an idea for a SaaS product, that involved some trickery with syncing various calendars. That is also where my know-how stems from. I was surprised how hard it was for me to get a firm understanding on how the iCal format really works. In this case my nativity really did not work in my favor.

The Definition

The current iCal version 2.0 obsoleted RFC2445 in September 2009. That is how the standard describes iCal:

The iCalendar format is suitable as an exchange format between applications or systems. The format is defined in terms of a MIME content type. This will enable the object to be exchanged using several transports, including but not limited to SMTP, HTTP, a file system, desktop interactive protocols such as the use of a memory-based clipboard or drag/drop interactions, point-to-point asynchronous communication, wired-network transport, or some form of unwired transport such as infrared. — RFC5545#S1

The standard defines the two most important objects: Calendar and Event. A minimal example looks like this:

BEGIN:VCALENDAR
PRODID:-//eigenmann.dev//ical
VERSION:2.0
BEGIN:VEVENT
DTSTART:20210324T090000Z
DTEND:20210324T100000Z
DTSTAMP:20210328T094322Z
UID:12345
END:VEVENT
END:VCALENDAR

It is a line-based format. Each element is surrounded by BEGIN:[ElementName] and END:[ElementName].

VCALENDAR: The calendar object. You can have multiple calendars, but it can’t be nested.

  • PRODID: globally unique product or service ID
  • VERSION: format version

VEVENT: The event object. An event must occur within a calendar object.

  • DTSTART: event start, specified without a timezone offset
  • DTEND: event end, same format as DTSTART
  • DTSTAMP: last updated, same format as DTSTART
  • UID: globally unique object ID

Timezones

At this point you will be able to communicate static snapshots of your calendar. As the keen eyed read might have spotted, is that all time references are without timezones attached. That’s what we are looking into next.

In iCal a date time can be “floating” or “fixed”. In this case of a floating timestamp, the event is not bound to a timezone but rather dependent on from what timezone you are looking at the event. That means that for me, 8am will be 8am regardless if I am in Switzerland or Canada. But if I want my colleagues to join the meeting, I’d better make sure I used fixed date times.

The easiest solution is to output the time in UTC. This can be done by appending the letter “Z” to the date time. If you need to specify the date time in your local timezone, you can do that by including the TZID property like this. (Make not of the “Z” that is now gone.)

BEGIN:VEVENT
DTSTART;TZID=Europe/Zurich:20210324T090000
DTEND;TZID=Europe/Zurich:20210324T100000
DTSTAMP;TZID=Europe/Zurich:20210328T094322
UID:12345
END:VEVENT

And you will also have to include a VTIMEZONE object with the corresponding TZID=Europe/Zurich property. You can get an up-to-date version from here. If you omit that definition you will get vastly different interpretations of your events. The format does allow you to use non-standard properties. One well known is X-WR-TIMEZONE:Europe/Zurich. This property should only be considered for informational purposes. Google for some time did not include the timezone objects and required the interpreter to look up the corresponding timezone from an olson table.

If you are tasked with implementing a parser, be aware that Microsoft has its own set of timezones.

All-day Events

In calendars all day events are a special kind of events. These events are not floating because it only used to describe date times when no timezone is attached. If you did not know already, in many calendar applications an all-day event is not marked as busy. But what will the interpreter do if it wants to know your free/busy state?

Some parsers will use the first timezone that is defined either in X-WR-TIMEZONE or as VTIMEZONE and apply use the full day from 0:00:00 to 23:59:59. Others might use the default timezone where the parser is running currently. And then there are implementations where the default timezone is configurable on per-user basis.

Recurrence

Some vendors will output every occurrence of an event. They will be identifiable by the presence of the RECURRENCE-ID property and will share the same UID. But the specified way is to use the RRULE property. Lets day you want a weekly event, every Monday and Friday morning from 9 to 10, for the next 10 weeks, you would use this line: RRULE:FREQ=WEEKLY;COUNT=20;WKST=MO;BYDAY=MO,FR

Now you cancel the meeting next Monday, you would add this line: EXDATE;TZID=Europe/Zurich;20240930T0900 and add the now moved event as separate object:

BEGIN:VEVENT
DTSTART;TZID=Europe/Zurich:20210325T090000
DTEND;TZID=Europe/Zurich:20210325T100000
DTSTAMP;TZID=Europe/Zurich:20210328T094322
RRULE:FREQ=WEEKLY;COUNT=20;WKST=MO;BYDAY=MO,FR`
EXDATE;TZID=Europe/Zurich;20210402T0900
UID:12345
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID=Europe/Zurich:20210402T100000
DTEND;TZID=Europe/Zurich:20210402T110000
DTSTAMP;TZID=Europe/Zurich:20210328T094322
UID:12345
RECURRENCE-ID;TZID=Europe/Zurich:20210302T090000
END:VEVENT

Notice that both events have the same UID and the moved one had the RECURRENCE-ID property with the value when it would have happened.

One thing I want to add here is that daylight saving time might be an off by 1 error. If you interpret your RRULE and cross into a different daylight configuration, you might want to check that you have the right time offset applied.

Cleanup

In my experience users not always know or realize that all-day events are free by default in most calendar applications. Especially if you are dependent on the free/busy flag to determine if someone is available, that might bring some confusion.

I have contributed some lines to iCalEvents, a python library that can parse iCal and unfold/expand these recurring events. But there are other implementations for php Zap Cal or node node-ical that might work better for you.