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.

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.

Note

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.

MacOS Calendar previewing an event "Now"

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
}
    

Diagram of a database page URL showing the database ID

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.