iCalendar - How Hard Could It Be?

At long last, I have a class schedule for my upcoming return to college. I noticed that my student calendar did not update after everything was locked into Workday, the university’s online system, and Workday itself did not offer a calendar export. I started to manually copy the events into my calendar, but I unfortunately had gotten the itch: surely there’s an automated way to do this!

I poked around and found a couple of unofficial sites from students at other universities that did what I was looking for… At least in theory. I ruled out one of the tools immediately since it required linking a Google account and didn’t yet have iCal export. The remaining two seemed to have worked for someone at some point, but did not work for my schedule. It’s hard to say for sure, but I think the ad-hoc structure of the schedule table that is presented by Workday might not be consistent between schools. There’s a basic class name, start/end date, and instructors field, but the schedule pattern itself certainly feels like it could be bespoke:

Monday/Wednesday/Friday | 10:45 AM - 11:35 AM | FOOBAR 102

Or, there can be a multi-pattern for classes with weirder scheduling, separated by a double newline:

08/17/2026 - 12/03/2026 | Monday/Wednesday/Friday | 12:55 PM - 1:45 PM | FOOBAR 206

08/17/2026 - 12/03/2026 | Thursday | 7:30 PM - 10:30 PM | ABC | No Classroom Required

In practice, while certainly brittle, parsing the data we need is pretty straightforward. It’s so straightforward that I thought it would make sense to just build the damn thing myself. EZPZ. I used the encoding/csv package to read through the spreadsheet (I will look into a spreadsheet -> CSV conversion later, but that’s well-trodden territory), wrote some nasty strings.Split and time.Parse glue to extract the details, and then I shoved everything into some structs. Now I just need to transform that into the iCal format, how hard could it be?

In 2026, I am very cautious about dependencies. Every other news cycle involves a compromised package, and between that, the massive dependency trees for most software, and the volume of AI slop that is in circulation, I avoid importing packages with their own dependency trees. In Go, the ecosystem is generally pretty disciplined about only taking on transient dependencies when it’s very necessary. For instance, gomarkdown/markdown, which I use for this blog, only depends on the standard library. This makes sense – most protocols and standard formats are expected to be implemented by humans – but it is certainly not the way that most software is built these days. Unfortunately for me, both iCal implementations I could find depended on at least one transient, one of which being a testing utility library with three of its own dependencies…

I’ve worked with iCal feeds through the Ruby icalendar gem before and I’ve debugged a handful of problems with feed structure, so now I’ve really backed myself into a corner. It’s just a text-based format, and I don’t need all of it. How hard could it be to implement what I need from scratch? I opened up RFC 5545 in one tab and iCalendar.org in another. In short…

We need to wrap the whole calendar in something that looks like this

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//your//product//name
CALSCALE:GREGORIAN
(calendar events go here)
END:VCALENDAR

Make sure that you’re using CRLF (\r\n) and not just UNIX LF (\n) for each new line. The only dynamic field here is the PRODID. What’s that wacky format? Duh, it’s ISO.9070.1991 as recommended by the iCal spec. On a more serious note, this is the first place I really started to notice the age of the iCal standard. I’ve never seen this ISO format anywhere else, and the standard being referenced is officially “withdrawn.” Of course, with millions/billions of iCalendar clients in the wild, this field will probably never change. Not that it really matters anyway, as it’s highly unlikely that a client would ever attempt to parse a structured ID in this format.

As for events, the subset of the standard that we need is relatively minimal. Classes effectively fall into a weekly cadence, recurring on specific days of the week, and the recurrence rule structure supports that in a straightforward way. This means that we only need one event per start/end time for a given class.

BEGIN:VEVENT
UID:(unique ID for event)
SUMMARY:(title)
DESCRIPTION:(description)
DTSTAMP:YYYYMMDDTHHMMSSNNZ
DTSTART:YYYYMMDDTHHMMSSNN
DTEND:YYYYMMDDTHHMMSSNN
RRULE:(recurrence rule)
END:VEVENT
UID This is a globally unique identifier for the event.
SUMMARY This is the event title. Must be folded at 75 octets.
DESCRIPTION This is the event description. Must be folded at 75 octets.
DTSTAMP This is the creation date of the event.
DTSTART For our purposes, this is iCal timestamp of the start of the first occurrence (date AND time)
DTEND For our purposes, this is iCal timestamp of the END of the FIRST occurrence (date AND time)
RRULE Recurrence rule, more on that later

Folding should occur for any line that exceeds 75 octets though the title and description are the most likely to wrap in our subset of the spec, and maybe the RRULE. The other thing to note is the timestamp format, which naturally does not use ISO8601 formatting. Instead they have their own format with its own special nightmarish time zone handling. I’ll discuss the timezones later in this document, but for now understand that it’s easiest to use the UTC format (tacking on the Z) for the DTSTAMP, and leaving the Z specifier off for the individual start/end times so that we have a “floating” time. Floating times are generally bad since it forces the client to infer the timezone, however the timezone specification is messy enough that I found this to be infinitely more straightforward.

Recurrence rules, if you need to parse them, can be an abject nightmare. Lucky for us, we just need to write a tiny subset for class schedules:

RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;UNTIL=YYYYMMDDTHHMMSSNNZ

This is mercifully straightforward. The only thing to note is that the UNTIL denotes the upper bounds of the event. This can be a little strange at first, but it finally clicked for me: DTSTART and DTEND are the start/end time of the first occurrence. The RRULE is responsible for repeating that same occurrence with different dates but the same times until the UNTIL time. The name DTEND definitely threw me off.

But that’s the entire subset of the spec that is necessary to populate a calendar! Here’s a basic schedule:

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//workdayics//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
SUMMARY:FOO 21001 - FOO BAR I LAB
DESCRIPTION:Lorem ipsum.
 Folded line.
UID:18aada2b-2236-bec0-baf2-50088d7d8dec
DTSTAMP:20260429T142800Z
DTSTART:20260817T140000
DTEND:20260817T164500
DESCRIPTION:
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO;UNTIL=20261211T000000Z
END:VEVENT
BEGIN:VEVENT
SUMMARY:FIZZ 20404 - UNIVERSITY FIZZBUZZ II
DESCRIPTION:Escaped\: character
UID:18aada2b-2236-c2a8-8a46-a58d1a7a97a8
DTSTAMP:20260429T142800Z
DTSTART:20260817T125500
DTEND:20260817T134500
DESCRIPTION:RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR;UNTIL=20261211T000000Z
END:VEVENT
END:VCALENDAR

I threw out the timezone specification earlier, and you might be wondering why. Unfortunately, this part of the spec is more egregiously outdated than even the strange product identifier structure. You see, this spec predates the ubiquity of the timezone DB (tzdb) that almost all software in the past twenty or so years has gravitated towards. As a developer working with the spec, that means you can’t just specify the timezone in use by its well-known tzdb identifier, e.g. America/Chicago. Instead, you’re responsible for defining the timezone in the calendar itself. This isn’t the end of the world, and I do intend to circle back to it, but it is certainly a drag. Furthermore, Go has its own special snowflake implementation of the time zones via time.Location that might not be as easy as other languages that just rely on the OS for timezone data.

Anyhow, that’s all I wanted to write about the spec today. I may follow up eventually to discuss the difficulty I had testing my iCal payloads, but for now I hope that maybe my notes can unblock someone that also needs such a limited portion of the iCal surface area. I’ll also have the tool I’ve written to perform the Workday -> iCal translation up on my Github in the near future.