Turning data into calendar feeds
I rely on Google Calendar and macOS Calendar to remind me of time commitments. Some events I care about are managed by other people, making them hard to track in my calendar apps.
- Local meetup groups - A local outdoors group posts events on their website. I often miss events or forget to add them to my calendar until it’s too late.
- Notion databases - I plan events collaboratively in a Notion database. Notion doesn’t have a built-in way to sync these events to calendar apps outside of Notion Calendar.
iCalendar feeds
Calendar clients support a basic way of reading dynamic calendar data from
external sources. You need a URL that hosts an
iCalendar feed, effectively a link to
a dynamic .ics
file.
- In Google Calendar add a calendar “From URL”
- In Apple Calendar add a “New Calendar Subscription”
- In Outlook “Subscribe from web”
You can produce data in the iCalendar format from JavaScript with the ical-generator package. How you source data for calendar events up to you. For Notion, I used the Notion API. For other sites, you may need to write custom scraping logic.
import { ICalCalendar } from "ical-generator"
const calendar = new ICalCalendar({ name: "My Calendar" })
// Add an event with start/end time
calendar.createEvent({
start: new Date("2024-12-31T23:00:00.000-05:00"),
end: new Date("2025-01-01T00:00:00.000-05:00"),
summary: "New Year's Eve",
location: "Times Square",
description: "Watch the ball drop before midnight.",
})
// Add an all-day event
calendar.createEvent({
start: new Date("2025-01-01T00:00:00.000-05:00"),
allDay: true,
summary: "New Year's Day",
location: "Times Square",
description: "Happy new year, all day!",
})
// Serialize data to iCalendar format
const ics = calendar.toString()
Initially I considered populating calendars with the Google Calendar API, but I preferred the iCalendar feed approach because:
- Google APIs are notoriously complicated to set up
- I don’t want to couple my efforts to the Google Calendar
Publishing feeds on the internet
Once you have code that produces an iCalendar feed, you need to host it on the internet. You can host this any way you like (e.g. Vercel, Netlify, Glitch, VPS), but I prefer Val Town for this sort of task.
With Val Town you can instantly create one-off HTTP endpoints that run JavaScript. These are lightweight functions powered by the Deno runtime. Because they’re so cheap and efficient, Val Town offers a generous free tier with unlimited public “Vals”. It’s similar to Glitch in that projects can handle backend requests at a public URL, but more lightweight and focused on single-file scripts reminiscent of JSFiddle.
// Endpoint handler to return an iCalendar feed
export default async function (req: Request): Promise<Response> {
const ics = "..." // String generated from `ical-generator`
return new Response(ics, {
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'attachment; filename="calendar.ics"',
},
})
}
For personal use, 10 calls / minute included in the free tier is plenty given that calendar clients poll for subscription updates at a much lower frequency (between every few minutes to every few days, depending on the configuration). If you’re publishing a feed that lots of people will subscribe to, you may want to use a more capable hosting service with a caching layer in front.
After adding a calendar to Google Calendar from a URL, Google will publish the data it receives through another public URL that you can share with others. This lets you mask the underlying endpoint and serve a larger audience without redundant requests.
Here’s a complete Val that serves an iCalendar feed with a single one hour event starting at the time of request.
After subscribing to the Val Town endpoint in my calendar client, I see the event appear.
Appendix: Notion databases
One use case I mentioned above is syncing events from a Notion database to a calendar client. Although you can manage events from Google Calendar in the Notion Calendar app, it doesn’t work the other way around. Third-party services like Intertwine or 2sync advertise the ability to sync events in Notion to Google Calendar, otherwise people suggest rolling your own sync solution using automation services like Zapier. I don’t like making myself dependent on third party services like this, so I prefer the custom iCal feed.
If you’d like to do the same. Create a Notion database with properties for Name, Date, and Location.
The following sample code (written against @notionhq/client version 2.2.15) parses events from the database as structured objects suitable for populating an iCalendar feed.
Click to expand code
import { Client, collectPaginatedAPI, isFullPage } from "@notionhq/client"
export async function getNotionDatabaseEvents({
databaseId,
token,
}: {
databaseId: string
token: string
}) {
const notion = new Client({
auth: token,
})
const allObjects = await collectPaginatedAPI(notion.databases.query, {
database_id: databaseId,
filter: {
property: "Date",
date: {
is_not_empty: true,
},
},
})
const allPages = allObjects.filter(isFullPage)
const allNotionDatabaseEvents = allPages.map((page) => {
const date = page.properties["Date"]
const name = page.properties["Name"]
const location = page.properties["Location"]
if (name.type !== "title" || date.type !== "date" || date.date === null) {
throw new Error("Invalid properties", page.properties)
}
let emoji: string | undefined
if (page.icon && page.icon.type === "emoji") {
emoji = page.icon.emoji
}
// TITLE
const namePlainText = name.title.reduce(
(acc, curr) => acc + curr.plain_text,
"",
)
let title = namePlainText
if (emoji) title = emoji + " " + title
// LOCATION
let locationPlainText: string | undefined
if (location.type === "rich_text") {
locationPlainText = location.rich_text.reduce(
(acc, curr) => acc + curr.plain_text,
"",
)
}
// DATES
const isAllDay = date.date.start.length === 10 // YYYY-MM-DD indicates this is an all day event
const dateStart = new Date(date.date.start)
const dateEnd = date.date.end ? new Date(date.date.end) : undefined
const notionDatabaseEvent = {
title,
url: page.url,
dateStart,
dateEnd,
isAllDay,
location: locationPlainText,
}
return notionDatabaseEvent
})
return allNotionDatabaseEvents
}
The database ID is the long code at the end of the database page URL (see Retrieve a Database). The token can be obtained by creating an “Internal” integration with “Read content” capabilities. Then add the integration to the database page you want it to read.