I made: An ICS calendar for my courses at WPI
October 5, 2025 • in i-made
Update Oct 20, 2025: I’ve put together a site at tools.calebc.co that contains the logic for the course calendar generator so that you can generate your own .ics calendar. It has a visual schedule editor, can understand .xlsx exports from Workday, and runs entirely in your browser. Please try it out and let me know if you run into any issues!
This week, I made tool that takes in a description of your course schedule and publishes an ICS calendar for you to import into your calendar app of choice.
It took a little over an hour (the amount of time I had on Thursday before I had to go to work) and is definitely not feature complete, but it works, and that’s good enough!
Course description format
I thought of making a web form that you could enter data into, but that would’ve taken too long (also I hate making HTML forms). Easier was to define a text format. I opted for KDL:
calendar "WPI - AY 2025-2026" enabled {
term A25 {
start "8/21/2025"
date "9/1/2025" { no-class "Labor Day" }
date "9/4/2025" { follow monday }
date "9/19/2025" { no-class "Wellness Day" }
end "10/10/2025"
class "CS 3431" "Database Systems I" {
meeting-pattern "M-R | 10:00 AM - 11:50 AM"
location "Washburn 229"
}
class "AR 2301" "Graphic Design" {
meeting-pattern "T-F | 10:00 AM - 11:50 AM"
location "Salisbury Labs 123"
}
// ...
}
// ...
}
example calendar definition
Each calendar has multiple terms (semesters, quarters in the case of WPI, or whatever other unit), which have a start and end date as well as a number of rules for dates with special handling: holidays are marked no-class with a corresponding reason, and days that follow a different schedule than the actual weekday they’re on are marked follow <weekday>.
Each course gets a course number, course name, meeting pattern, and location. The meeting patterns define on which weekdays and what time the lecture happens. The wacky string format for meeting patterns is the same as Workday’s format. Copy and paste away!
These course definitions (and an API token) would be sent base64’d to the server in the calendar URL. I could then use my own Go KDL parsing library to parse the document and turn it into calendar events. (I could’ve stored the course descriptions in a database so that the server could remember it for the future, but, again, that would’ve taken too long.)
It turns out that base64’ing a few kilobytes of text makes for an extremely long URL, so I gzipped the KDL before base64 encoding it. I made a little webpage to automate this process so I can just paste the KDL and a token and get a working calendar URL out (Side note: the ergonomics of the Web Streams API are horrendous.)
async function encodeCalendar(calendar) {
const encoded = new TextEncoder().encode(calendar)
const gzipped = await new Response(encoded).body.pipeThrough(
new CompressionStream('gzip')
)
return rawBase64URLEncode(await new Response(gzipped).arrayBuffer())
}
function rawBase64URLEncode(arrayBuffer) {
return new Uint8Array(arrayBuffer).toBase64({
alphabet: 'base64url',
omitPadding: true,
})
}
On the server, I can undo the encoding steps and parse the original KDL out. Because I’m using my own half-baked KDL library, I have to write custom unmarhsalling logic for each struct, which was the majority of the time spent on this project. (One day I’ll properly write a decoder/encoder that uses struct tags, but today isn’t that day!) Sure, I could’ve used JSON, but would that’ve been as pretty as KDL? No way.
doc, err := kdl.NewParser(kdl.KdlVersion2,
gzip.NewReader(
base64.NewDecoder(base64.RawURLEncoding,
strings.NewReader(kdlParam)
)
)
).ParseDocument()
for _, node := range doc.Nodes {
var calendar FullCalendar
if err := calendar.UnmarshalKDL(node); err != nil {
return nil, err
}
calendars = append(calendars, &calendar)
}
Much nicer streams API. JavaScript, take note!
Then, it’s as simple as looping through each day of the term, figuring out what classes go on those days, and creating events for them using a Go iCalendar library. If a no-class rule was defined for a certain day, it skips checking the classes and puts an all-day event on the calendar with the reason why. Similarly, if a follow rule exists, it changes the weekday that the classes are checked against and puts an all-day event as a reminder on the calendar.
I couldn’t be bothered to deal with time zones, so I just hardcoded America/New_York in there. What could possibly go wrong?
for _, class := range term.Classes {
if !slices.Contains(class.MeetingPattern.Weekdays, db.Weekday{Weekday: weekday}) {
continue
}
courseNumber := strings.ReplaceAll(class.CourseNumber, " ", "")
ev := cal.AddEvent(fmt.Sprintf("%s-%s", date, courseNumber))
ev.SetSummary(fmt.Sprintf("%s - %s", class.CourseNumber, class.Name))
ev.SetStartAt(date.ToTimeWith(class.MeetingPattern.StartTime))
ev.SetEndAt(date.ToTimeWith(class.MeetingPattern.EndTime))
if class.Location.Valid {
ev.SetLocation(class.Location.String)
}
}
checking classes to add to the calendar
And that’s it! A few more bits to add authentication tokens (just in case), and then I can add the URL to my calendar app and see those events come in:

I’ll probably store the course definitions in a database down the road so I don’t have to change the URL in my calendar app every time I need to change the courses. Support for multiple meeting patterns, like for discussion and lab sections, would also be nice to add. Problem for future me!
That’s all for this week! Thanks for reading!