diff --git a/apps/web/app/(home)/blog/[slug]/page.tsx b/apps/web/app/(home)/blog/[slug]/page.tsx new file mode 100644 index 0000000..b5d58f6 --- /dev/null +++ b/apps/web/app/(home)/blog/[slug]/page.tsx @@ -0,0 +1,101 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { JsonLd } from '@/components/json-ld'; +import { getMDXComponents } from '@/components/mdx'; +import { pageMetadata, website } from '@/lib/seo'; +import { appName, author, formatPostDate, githubUrl, siteUrl } from '@/lib/shared'; +import { posts } from '@/lib/source'; + +function getPost(slug: string) { + const entry = posts.find((p) => p.slug === slug); + if (!entry) notFound(); + return entry.post; +} + +export default async function Post(props: PageProps<'/blog/[slug]'>) { + const { slug } = await props.params; + const post = getPost(slug); + const MDX = post.body; + + return ( +
+ + + ← Blog + +

+ {post.title} +

+

{post.description}

+

+ + {author.name} + {' '} + · +

+ {/* Long inline code, like a full property path, would push the page sideways on phones. */} +
+ +
+
+

+ calsdk is an open source TypeScript library with one API for Google Calendar, Outlook, + iCloud, CalDAV, Zoom, Cal.com, Calendly and ICS feeds. +

+
+ + Try the quickstart + + + Star on GitHub + +
+
+
+ ); +} + +export function generateStaticParams() { + return posts.map(({ slug }) => ({ slug })); +} + +export async function generateMetadata(props: PageProps<'/blog/[slug]'>): Promise { + const { slug } = await props.params; + const post = getPost(slug); + return pageMetadata({ + title: post.title, + description: post.description, + path: `/blog/${slug}`, + image: `/og/blog/${slug}/image.png`, + }); +} + +function postJsonLd(slug: string, post: ReturnType) { + const url = `${siteUrl}/blog/${slug}`; + return { + '@context': 'https://schema.org', + '@graph': [ + website, + { + '@type': 'BlogPosting', + '@id': `${url}#article`, + headline: post.title, + description: post.description, + datePublished: post.date, + url, + image: `${siteUrl}/og/blog/${slug}/image.png`, + inLanguage: 'en', + author: { '@type': 'Person', name: author.name, url: author.url }, + isPartOf: { '@id': website['@id'] }, + about: { '@type': 'SoftwareSourceCode', name: appName, codeRepository: githubUrl }, + }, + ], + }; +} diff --git a/apps/web/app/(home)/blog/page.tsx b/apps/web/app/(home)/blog/page.tsx new file mode 100644 index 0000000..5b73a00 --- /dev/null +++ b/apps/web/app/(home)/blog/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { pageMetadata } from '@/lib/seo'; +import { formatPostDate } from '@/lib/shared'; +import { posts } from '@/lib/source'; + +export const metadata: Metadata = pageMetadata({ + title: 'Blog', + description: + 'Notes from building calsdk: how Google Calendar, Outlook and CalDAV really behave, and what it takes to put one API over them.', + path: '/blog', +}); + +export default function BlogIndex() { + return ( +
+

+ Blog +

+
    + {posts.map(({ post, slug }) => ( +
  • + + +

    + {post.title} +

    +

    + {post.description} +

    + +
  • + ))} +
+
+ ); +} diff --git a/apps/web/app/og/blog/[slug]/image.png/route.tsx b/apps/web/app/og/blog/[slug]/image.png/route.tsx new file mode 100644 index 0000000..8e68cc1 --- /dev/null +++ b/apps/web/app/og/blog/[slug]/image.png/route.tsx @@ -0,0 +1,22 @@ +import { generateOGImage } from 'fumadocs-ui/og'; +import { notFound } from 'next/navigation'; +import { appName } from '@/lib/shared'; +import { posts } from '@/lib/source'; + +export const revalidate = false; + +export async function GET(_req: Request, { params }: RouteContext<'/og/blog/[slug]/image.png'>) { + const { slug } = await params; + const entry = posts.find((p) => p.slug === slug); + if (!entry) notFound(); + + return generateOGImage({ + title: entry.post.title, + description: entry.post.description, + site: appName, + }); +} + +export function generateStaticParams() { + return posts.map(({ slug }) => ({ slug })); +} diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts index 88d403c..3df6320 100644 --- a/apps/web/app/sitemap.ts +++ b/apps/web/app/sitemap.ts @@ -1,6 +1,6 @@ import type { MetadataRoute } from 'next'; import { siteUrl } from '@/lib/shared'; -import { source } from '@/lib/source'; +import { posts, source } from '@/lib/source'; export default function sitemap(): MetadataRoute.Sitemap { return [ @@ -10,5 +10,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: 'weekly' as const, priority: page.slugs.length === 0 ? 0.9 : 0.7, })), + { url: `${siteUrl}/blog`, changeFrequency: 'weekly', priority: 0.7 }, + ...posts.map(({ post, slug }) => ({ + url: `${siteUrl}/blog/${slug}`, + lastModified: post.date, + changeFrequency: 'monthly' as const, + priority: 0.8, + })), ]; } diff --git a/apps/web/content/blog/finding-free-time-across-calendars.mdx b/apps/web/content/blog/finding-free-time-across-calendars.mdx new file mode 100644 index 0000000..8ce1fe4 --- /dev/null +++ b/apps/web/content/blog/finding-free-time-across-calendars.mdx @@ -0,0 +1,128 @@ +--- +title: Finding a free slot across Google, Outlook and iCloud +description: Each calendar API answers "when is this person busy?" differently. Here's what each one gives you, and how to merge them without double booking. +date: 2026-11-06 +--- + +Here's a feature request that sounds like an afternoon's work: show the times when Ana (on Google Calendar) and Raj (on Outlook) are both free for 30 minutes next week, during Berlin working hours. + +You fetch two lists of busy times, merge them and look for the gaps. The merge really is the easy part. The hard part is getting a busy time you can trust from each provider, because the three big calendar APIs each answer "when is this person busy?" in a different way, and one of them barely answers it at all. + +## Google: a real free/busy endpoint + +Google is the easy one. You `POST /freeBusy` with a window and a list of calendar ids, and get back busy ranges per calendar: + +```json +{ + "calendars": { + "primary": { + "busy": [{ "start": "2026-11-09T08:30:00Z", "end": "2026-11-09T09:00:00Z" }] + }, + "team@example.com": { + "errors": [{ "domain": "global", "reason": "notFound" }] + } + } +} +``` + +There are two things to notice. First, Google has already applied its own rules: an event marked "show as free" (`transparency: "transparent"`) isn't in there, and it works for calendars the user can only see as free/busy, where listing events would give you nothing. Second, a failure doesn't fail the request. A calendar that wasn't found, or that isn't shared with you, comes back with an `errors` array and no `busy` key. If your code reads `busy ?? []`, that calendar now looks completely free. That isn't an edge case. It's the default way to get it wrong. + +The only limit worth knowing is that one request covers at most 50 calendars, so a big team needs batching. + +## Outlook: an endpoint you probably can't use + +Microsoft Graph does have a free/busy endpoint, `getSchedule`. You give it email addresses and a window, and it gives you back an `availabilityView` string with one digit per time slot: + +```json +{ "scheduleId": "raj@contoso.com", "availabilityView": "000220130" } +``` + +`0` is free, `1` tentative, `2` busy, `3` out of office and `4` working elsewhere. The slot length (`availabilityViewInterval`) defaults to 30 minutes and goes from 5 to 1440. There's also a `scheduleItems` array with the actual start and end of each item, which is what you want: the digit string rounds everything to the slot length. + +The catch is in the permissions table: `getSchedule` doesn't support personal Microsoft accounts. If your users sign in with an outlook.com or hotmail.com address, it's not an option. On a busy shared calendar it also gives up past 1000 entries in the window. + +The other route works for every account: list the events with `calendarView`, which expands recurring series into occurrences on the server, and work out busy time yourself. That's what calsdk does for Outlook. It does mean you now own the rules Graph would have applied for you, starting with `showAs`. Outlook has six values (`free`, `tentative`, `busy`, `oof`, `workingElsewhere` and `unknown`), and you have to decide which ones block time. + +Interestingly, `getSchedule` writes "working elsewhere" as `0`, which is free. I went the other way and count everything except `free` as busy. Someone working from another office, or tentatively holding a slot, usually doesn't want a stranger booking it. + +Time zones need care too. Graph names zones the Windows way, like `"Pacific Standard Time"`, not `"America/Los_Angeles"`, and a `Prefer: outlook.timezone` header changes which zone it writes times in. For busy time, ask for UTC with `Prefer: outlook.timezone="UTC"` and you skip the mapping entirely. + +## iCloud and CalDAV: compute it yourself + +CalDAV is the protocol behind iCloud, Fastmail, Nextcloud, Zoho and most self-hosted calendars. On paper it has free/busy too: RFC 4791 defines a `free-busy-query` REPORT. In practice I didn't want a feature where whether it works depends on which server the user happens to be on, so calsdk treats CalDAV the same way as Outlook: list the events and compute. + +Listing is a `calendar-query` REPORT with a time range: + +```xml + + + + + + + + + + +``` + +What comes back is raw iCalendar text, and everything is now your problem. Recurring events arrive as one `VEVENT` with an `RRULE` and its exceptions, so you expand the occurrences yourself. Servers read floating times and all-day dates in a zone of their choosing, so calsdk asks for a day of slack on either side and filters the results itself. Busy or free is the `TRANSP` property (`TRANSPARENT` means free), and cancelled occurrences have `STATUS:CANCELLED`. + +Some servers have extra quirks. Zoho answers a calendar query without the event data, so every event has to be fetched again with a `calendar-multiget`. If one of those fetches fails, it's tempting to skip that event. Don't: a missing event is time that looks free. calsdk fails the whole listing instead. + +## The gotchas that cut across all three + +All-day events are dates, not instants. A holiday on November 9 means November 9 in the calendar's own time zone, so to turn it into a busy range you need that zone, which you only get from the calendar list. If you can't get it, the safe choice is to block the day in every zone, from UTC+14 through UTC-12. That covers more than a day, but it never shows a holiday as free. + +Private events still block time. You won't see the title, but you need the range. + +Declined invitations are a judgment call. The event fallback in calsdk only looks at the event's own free/busy flag and whether it's cancelled, not at your RSVP. So a declined invite that stays on the calendar still counts as busy. That's a choice, and it errs the same way as everything else: better to hide a free slot than to offer a taken one. + +Rate limits are real once you list a week of events for 20 people. Retry `429`s after the `Retry-After` the provider sends, and keep the window as small as the feature allows. + +## The merge, and why it should fail closed + +With trustworthy busy ranges in hand, the algorithm is short: + +1. Put every busy range from every calendar into one list, clip it to the window, sort by start and merge ranges that overlap or touch. +2. Walk candidate start times (every 15 minutes, say) inside working hours, in the organizer's time zone. +3. Keep a candidate if `[start, start + duration)` doesn't overlap any merged range. + +Working hours need more care than they seem to. "9 to 5 in Berlin" is a different UTC range on either side of a daylight saving change, and on the day the clocks spring forward an hour of wall-clock time simply doesn't exist. calsdk places slots on the local wall clock within each stretch of constant UTC offset, so the grid stays on the hour after the change and the missing hour gets no slots. + +The important decision is what to do when one calendar can't be read. Remember Google's per-calendar `errors`. If Raj's calendar comes back with an error and you carry on with Ana's alone, every slot you show is a guess about Raj. A booking page that confidently offers a time that clashes is worse than one that says "couldn't check Raj's calendar". So `findFreeTime` fails closed: it throws `FreeBusyUnavailableError`, with the `provider` and `calendarId`, rather than return slots that might clash. + +## How calsdk does it + +calsdk wraps all of the above in two functions. `getFreeBusy` gives you merged busy ranges for one provider. It uses Google's endpoint where it exists, and lists events everywhere else, returning a `compatibility` warning so you know which path it took. `findFreeTime` takes calendars from any mix of providers and gives you the slots: + +```ts +import { findFreeTime, FreeBusyUnavailableError } from '@cal-sdk/core'; +import { icloud } from '@cal-sdk/caldav'; +import { google } from '@cal-sdk/google'; +import { microsoft } from '@cal-sdk/microsoft'; + +try { + const { slots, warnings } = await findFreeTime({ + calendars: [ + { calendar: google({ accessToken: anaToken }) }, + { calendar: microsoft({ accessToken: rajToken }), calendarIds: ['primary', roomCalendarId] }, + { calendar: icloud({ appleId, appPassword }) }, + ], + timeMin: '2026-11-09T00:00:00Z', + timeMax: '2026-11-14T00:00:00Z', + duration: 30, + workingHours: { start: '09:00', end: '17:00', timeZone: 'Europe/Berlin' }, + }); + // slots: [{ start: '2026-11-09T08:00:00.000Z', end: '2026-11-09T08:30:00.000Z' }, ...] +} catch (error) { + if (FreeBusyUnavailableError.isInstance(error)) { + // Tell the user which calendar couldn't be read, instead of guessing. + } + throw error; +} +``` + +Slots come back as UTC instants, ready to show in each viewer's own zone. The [free time guide](/docs/guides/free-time) covers slot intervals, overlapping slots and booking the one the user picks. + +If you want to poke at it without connecting a real account, the [quickstart](/docs/quickstart) runs against an in-memory calendar in a couple of minutes. diff --git a/apps/web/content/blog/incremental-calendar-sync.mdx b/apps/web/content/blog/incremental-calendar-sync.mdx new file mode 100644 index 0000000..dd36ede --- /dev/null +++ b/apps/web/content/blog/incremental-calendar-sync.mdx @@ -0,0 +1,113 @@ +--- +title: Three ways a calendar tells you what changed +description: Google sync tokens, Microsoft Graph delta links and CalDAV sync-collection all answer "what changed?", and each one fails in its own way. +date: 2026-11-06 +--- + +Sooner or later, every app that shows a user's calendar wants its own copy of it. You want to search events in your database, join them with your own data, and show a week without waiting on three slow APIs. So you copy the calendar into a table, and then you have the real problem: keeping that table correct without downloading the whole calendar every five minutes. + +Google Calendar, Microsoft Outlook and CalDAV servers like iCloud can all tell you "here's what changed since last time". They just do it in three quite different ways, and each one breaks in its own way. Here's what I learned wiring up all three. + +## Google: sync tokens + +Google has the cleanest model. You list a calendar's events once with no filters, follow `pageToken` through every page, and the last page hands you a `nextSyncToken`: + +```http +GET /calendar/v3/calendars/primary/events?maxResults=2500 + +{ "items": [ ... ], "nextPageToken": "CiAKGjBpNDd2..." } +... +{ "items": [ ... ], "nextSyncToken": "CPDAlvWDx70CEPDAlvWDx70CGAU=" } +``` + +Next time, you send that token back and get only what changed since then. Deleted events come back too, as items with `"status": "cancelled"`, and Google always includes them on a sync. You can't hide them, and you wouldn't want to, because that's how your copy learns about deletions. + +The catch is that the token remembers the query that created it. The `events.list` reference names the parameters you can't combine with `syncToken`: `iCalUID`, `orderBy`, `privateExtendedProperty`, `q`, `sharedExtendedProperty`, `timeMin`, `timeMax` and `updatedMin`. The one that bites people is `timeMin`. It's natural to sync "the next three months", but a sync token covers the whole calendar, so your first sync has to read the whole calendar too. Everything else you pass should match the initial request exactly. + +Tokens also expire, for example after a while unused, or when the calendar's sharing changes. Google then answers `410 Gone`, and the only fix is the one Google recommends: wipe your copy and do a full sync again. There's no way to resume from a partial state. + +One more detail: if you sync without `singleEvents=true`, a recurring meeting arrives as one series event with an RRULE, and any occurrence someone moved or edited arrives as its own exception event. That's what you want for a copy, since an endless daily standup has no finite list of occurrences to sync. + +## Outlook: delta queries over a window + +Microsoft Graph uses delta queries, and for calendars the one that works across a whole range is `calendarView/delta`. Unlike Google, it requires a window up front: + +```http +GET /me/calendarView/delta?startDateTime=2025-11-06T00:00:00Z&endDateTime=2028-11-06T00:00:00Z +Prefer: odata.maxpagesize=200 +``` + +Each page ends with either an `@odata.nextLink` (more pages in this round) or an `@odata.deltaLink` (the round is done). You don't build the next request yourself: Graph encodes your parameters, the window included, into those links, and you call them as they are. The delta link is your sync cursor. + +That has a consequence that's easy to miss: the window is fixed when you start. If you begin syncing on November 6th with "the next two years", you're still syncing that same two years next summer. To move the window you start a new round from scratch. + +Deletions come back as stubs with an `@removed` annotation: + +```json +{ "id": "AAMkAGI2...", "@removed": { "reason": "deleted" } } +``` + +Graph's docs warn that `@removed` covers two cases: events in the window that were deleted, and events outside the window that changed. An event moved from inside your window to outside it looks exactly like a deletion, which, for a copy of that window, is the right answer anyway. + +Two more things surprised me. The same event can appear more than once in a round, so you have to apply changes in order and let the later one win. And recurring meetings come back as a series master plus every occurrence in the window, where unmodified occurrences are thin stubs. For a copy, you keep the master and the exceptions and drop the stubs. To know which occurrences of a series were deleted, you read the master's `cancelledOccurrences`, which costs one more request per changed series. + +Expiry looks different too. Depending on the case, Graph answers `410 Gone` or an error code like `SyncStateNotFound`, `SyncStateInvalid` or `resyncRequired`. All of them mean the same thing: start over. + +## CalDAV: sync-collection, and the CTags before it + +CalDAV is the protocol behind iCloud, Fastmail, Nextcloud, Zoho and many self-hosted servers. It speaks WebDAV over XML, and its sync story has two generations. + +The old way is the CTag, a calendarserver.org extension: one `getctag` property on the calendar that changes whenever anything in it changes. It tells you *that* something changed, not *what*. So you then list the ETag of every event resource with a `PROPFIND`, compare them with the ETags you stored, and fetch the ones that differ. It works, but every check reads the whole calendar's list of ETags. + +The modern way is RFC 6578's `sync-collection` REPORT. You send the token from last time (an empty one for the first sync): + +```xml + + http://example.com/sync/1234 + 1 + + +``` + +The server answers with the resources that changed (with their new ETags), the ones that were removed (with a `404 Not Found` status), and a new token. Then you fetch the changed ones' iCalendar data with a `calendar-multiget` REPORT, in batches. + +There are a few rough edges. A server can truncate the answer, which it signals with a `507 Insufficient Storage` status on the collection itself, and you ask again from the new token. A token the server no longer accepts fails the `DAV:valid-sync-token` precondition, usually as a `403` or `409` (some servers use `410`), and you start over. Some servers also list long-deleted resources in an initial sync, so you can't treat every `404` as "delete this" when you had nothing to begin with. + +And one CalDAV resource is a whole series: the master and all its exceptions live in one `.ics` file. When it changes, you have to compare its exceptions with the ones you saw last time to find out which were removed. + +## Calendars with no change feed + +Not every API has any of this. Calendly and Zoom can't list meetings by when they last changed. The honest approach there is to read every meeting each time and compare it with what you saw last time. To keep that cheap, you store a short hash per event rather than the event itself, so your "cursor" is a list of ids and fingerprints that grows with the calendar. ICS feeds are the same, except the server can save you the download with `304 Not Modified` if you send its `ETag` back. Cal.com sits in between: it can list bookings sorted by when they were updated, so you can read from the last change you saw (with some overlap, because a slow write can land a little out of order). + +These cursors never expire, since there's no server state behind them. They're just a record of what you saw. + +## How calsdk handles it + +I built [calsdk](/docs) partly because I got tired of writing this four times. `syncEvents` wraps all of the above behind one call: you apply the changes, save `nextCursor`, and catch `SyncCursorExpiredError` to start over. + +```ts +import { SyncCursorExpiredError, syncEvents } from '@cal-sdk/core'; + +async function sync(calendarId: string) { + const stored = await db.syncCursors.get(calendarId); + try { + const { changes, nextCursor } = await syncEvents({ calendar, calendarId, cursor: stored }); + for (const change of changes) { + if (change.type === 'upsert') await db.events.put(change.event); + else await db.events.delete(change.eventId); + } + await db.syncCursors.set(calendarId, nextCursor); + } catch (error) { + if (!SyncCursorExpiredError.isInstance(error)) throw error; + await db.events.clear(calendarId); + await db.syncCursors.delete(calendarId); + await sync(calendarId); + } +} +``` + +Under the hood it's exactly the approaches above. Google uses sync tokens over the whole calendar, and Outlook uses a delta link over a window (a year back and two years ahead by default, which you can change with `providerOptions.microsoft.timeMin` and `timeMax` on the first sync). CalDAV uses `sync-collection` and doesn't fall back to CTags, so a server without RFC 6578 won't sync. Calendly, Zoom and ICS compare fingerprints. Every provider returns the same thing: series, exceptions and single events, never computed occurrences. The cursor is an opaque string, so you store it and never parse it. + +Save the cursor only after the changes are applied. If your process dies in between, the next run replays them instead of losing them. + +The [sync guide](/docs/guides/sync) covers paging through a large first sync and how much each provider reads per round. If you want to see the API without connecting a real account, the [quickstart](/docs/quickstart) runs against an in-memory calendar. diff --git a/apps/web/content/blog/recurring-events-across-calendar-apis.mdx b/apps/web/content/blog/recurring-events-across-calendar-apis.mdx new file mode 100644 index 0000000..f5ec0ed --- /dev/null +++ b/apps/web/content/blog/recurring-events-across-calendar-apis.mdx @@ -0,0 +1,153 @@ +--- +title: One standup, three calendar APIs +description: How Google Calendar, Outlook and CalDAV store the same recurring meeting and its exceptions, and what it takes to put one API over all three. +date: 2026-11-06 +--- + +A daily standup is the most boring event on any calendar. It's also where calendar APIs disagree the most. + +Here's the event I'll use for the rest of this post: a standup at 9:30 in Berlin, every weekday, ten times, starting Monday, November 9. Someone cancels Tuesday's. Someone else moves Friday's to 10:00. Any calendar UI can show that in a second, but each of the three big APIs stores it in its own way, and an app that supports all three has to read and write every version. + +I've spent a lot of time with these differences while building [calsdk](/docs), so here is how each provider stores that week, and the places where it caught me out. + +## Google: RRULE, plus objects for everything else + +Google is the closest to the standard. The series is one event with a `recurrence` array of RFC 5545 lines: + +```json +{ + "id": "abc123", + "summary": "Standup", + "start": { "dateTime": "2026-11-09T09:30:00+01:00", "timeZone": "Europe/Berlin" }, + "end": { "dateTime": "2026-11-09T09:45:00+01:00", "timeZone": "Europe/Berlin" }, + "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10"] +} +``` + +The changes are separate objects. Friday's move is an instance with its own id (the series id plus the original start in UTC), a `recurringEventId` pointing back at the series, and an `originalStartTime` that says which slot it replaced: + +```json +{ + "id": "abc123_20261113T083000Z", + "recurringEventId": "abc123", + "originalStartTime": { "dateTime": "2026-11-13T09:30:00+01:00", "timeZone": "Europe/Berlin" }, + "start": { "dateTime": "2026-11-13T10:00:00+01:00", "timeZone": "Europe/Berlin" } +} +``` + +Tuesday's cancellation is the first surprise. You might expect an `EXDATE` line in the series' `recurrence`, and Google does accept those. But deleting an instance through the API leaves a third kind of object: an instance with `status: "cancelled"`. So there are two ways to say "not on Tuesday", and your code has to handle both. + +For reading, `singleEvents=true` expands the series on Google's side and gives you one object per occurrence. That's convenient, but the expanded list doesn't tell you which occurrences were edited. Friday looks like every other day, just at 10:00. If your UI wants to say "this occurrence was changed", you need a second request with `singleEvents=false` to find the exceptions. + +The hard part is "this and following events". Google Calendar's UI offers it, but the API has no such operation. You build it yourself: end the old series just before the chosen occurrence, and create a new series starting there. If the rule has a `COUNT`, you split the count between the two halves. Then you learn that Google resets edited occurrences when a series moves or splits, so Friday's change to 10:00 is lost unless you carry it over. + +## Outlook: patterns instead of rules + +Microsoft Graph doesn't use RRULE at all. A series is a `seriesMaster` with a `recurrence` object made of a pattern and a range: + +```json +{ + "id": "AAMkAGI2...", + "type": "seriesMaster", + "subject": "Standup", + "originalStartTimeZone": "W. Europe Standard Time", + "recurrence": { + "pattern": { + "type": "weekly", + "interval": 1, + "daysOfWeek": ["monday", "tuesday", "wednesday", "thursday", "friday"], + "firstDayOfWeek": "monday" + }, + "range": { + "type": "numbered", + "startDate": "2026-11-09", + "numberOfOccurrences": 10, + "recurrenceTimeZone": "W. Europe Standard Time" + } + } +} +``` + +Two things stand out. First, time zones have Windows names: Berlin is `W. Europe Standard Time`, and New York is `Eastern Standard Time`. So the first thing you need is a mapping table to IANA names, and calsdk ships one generated from the CLDR data. + +Second, a pattern can't express everything an RRULE can. Daily, weekly on set days, and monthly or yearly on a date or an "nth weekday" all map cleanly. Hourly rules, `RDATE`, and most combinations of `BYDAY` with `BYMONTHDAY` have no pattern. Some rules map but behave differently. `RRULE:FREQ=MONTHLY;BYMONTHDAY=31` skips months without a 31st, while Outlook moves that occurrence to the last day of the month. If you translate it naively, the user gets a meeting on February 28 that they never asked for. The RRULE that matches Outlook's behavior is `BYMONTHDAY=28,29,30,31;BYSETPOS=-1`, and that's how calsdk reads it back. + +Instances have a `type` of `occurrence` or `exception` and a `seriesMasterId`. Cancellations are where Outlook surprised me most: there's no exclusion list. Outlook only remembers occurrences that were deleted one by one, as `cancelledOccurrences` on the master. Graph only returns that when you `$select` it on a single event by id, so you need one extra request per series to learn which days are gone. And because no list exists, you can't exclude a date by writing an `EXDATE`. You delete that occurrence instead. + +There's no "this and following" here either. You end the series and create a new one, with the same bookkeeping as on Google. + +## CalDAV: the series is a file + +iCloud, Fastmail, Nextcloud and Zoho all speak CalDAV. CalDAV stores calendars as iCalendar files on a server, so the whole series, with every exception, is one `.ics` resource: + +```text +BEGIN:VEVENT +UID:standup-7f3a +DTSTART;TZID=Europe/Berlin:20261109T093000 +DTEND;TZID=Europe/Berlin:20261109T094500 +RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10 +EXDATE;TZID=Europe/Berlin:20261110T093000 +SUMMARY:Standup +END:VEVENT +BEGIN:VEVENT +UID:standup-7f3a +RECURRENCE-ID;TZID=Europe/Berlin:20261113T093000 +DTSTART;TZID=Europe/Berlin:20261113T100000 +DTEND;TZID=Europe/Berlin:20261113T101500 +SUMMARY:Standup +END:VEVENT +``` + +(I've left out the `VCALENDAR` wrapper and the `VTIMEZONE` block.) Tuesday is an `EXDATE`. Friday is a second `VEVENT` with the same `UID` and a `RECURRENCE-ID` naming the slot it replaces. It's the most honest of the three formats, and the most work to use. + +There are no instance ids at all. If your app needs to point at "Friday's standup", you have to make up an id. calsdk uses the resource name plus the original start in UTC, such as `standup-7f3a_20261113T083000Z`. + +Editing one occurrence means rewriting the whole file. You read it, change or add a `VEVENT`, and `PUT` it back with `If-Match` on the ETag, so you don't overwrite someone else's change made in the meantime. + +Then you expand the series yourself. CalDAV does let you ask the server to expand a series, but calsdk asks the server only for the resources in a time window, and expands them locally with ical.js. That gives one code path for every CalDAV server, and the same code reads plain ICS feeds, which have no server to ask. Expanding locally has its own traps. ical.js's own event expansion reads any `TZID` it has no definition for as UTC, so an `EXDATE` or `UNTIL` compared with a start in New York can be hours off and fail to match. calsdk builds the set of occurrences itself to avoid that. A rule like `FREQ=SECONDLY` from 2009 would expand forever, so expansion is capped, and those rules list only their first instance with a warning. + +Servers vary in small ways too. Zoho, for example, answers a calendar query without the event data, so every event has to be fetched again. + +## Putting one API over all three + +The design choice that made the rest possible was to speak RFC 5545 everywhere. Google and CalDAV already use it, so only Outlook needs translating, in both directions. Every event has a `kind`: `single`, `series`, `occurrence` or `exception`. Occurrences and exceptions carry a `seriesId` and an `originalStart`, which stays put when the occurrence moves. Here's the standup week, which works the same with `microsoft()` or `icloud()` in place of `google()`: + +```ts +import { createEvent, deleteEvent, listEvents, updateEvent } from '@cal-sdk/core'; +import { google } from '@cal-sdk/google'; + +const calendar = google({ accessToken: () => getFreshAccessToken() }); + +await createEvent({ + calendar, + event: { + title: 'Standup', + start: { dateTime: '2026-11-09T09:30:00', timeZone: 'Europe/Berlin' }, + end: { dateTime: '2026-11-09T09:45:00', timeZone: 'Europe/Berlin' }, + recurrence: ['RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=10'], + }, +}); + +const { events } = await listEvents({ + calendar, + timeMin: '2026-11-09T00:00:00Z', + timeMax: '2026-11-14T00:00:00Z', +}); + +// Tuesday is cancelled, Friday moves to 10:00. +await deleteEvent({ calendar, eventId: events[1].id }); +await updateEvent({ + calendar, + eventId: events[4].id, + patch: { + start: { dateTime: '2026-11-13T10:00:00', timeZone: 'Europe/Berlin' }, + end: { dateTime: '2026-11-13T10:15:00', timeZone: 'Europe/Berlin' }, + }, +}); +``` + +`updateEvent` and `deleteEvent` take a `scope` for instances: `'this'` (the default), `'all'`, or `'thisAndFollowing'`. On Google, calsdk does the split for `thisAndFollowing` itself, and if the second write fails, it deletes the new series so you aren't left with both. Outlook and CalDAV throw `UnsupportedFunctionalityError` for that scope, and `calendar.capabilities.events.recurrenceScopeThisAndFollowing` tells you beforehand, so your UI can hide the option. + +The rule I held to is to never save a schedule that's different from the one you asked for. If Outlook can't store a rule exactly, calsdk throws instead of saving something close. If the call can still do what you meant, but something is lost along the way, such as Google resetting edited occurrences when a series moves, the call succeeds and returns a warning that says so. A standup that quietly lands on the wrong days is worse than an error. + +The [recurring events guide](/docs/guides/recurring-events) has every scope and the limits for each provider. If you want to see it work without connecting an account, the [quickstart](/docs/quickstart) runs against an in-memory calendar. diff --git a/apps/web/lib/layout.shared.tsx b/apps/web/lib/layout.shared.tsx index cc4e5ae..8e73d97 100644 --- a/apps/web/lib/layout.shared.tsx +++ b/apps/web/lib/layout.shared.tsx @@ -5,7 +5,10 @@ import { githubUrl } from './shared'; export function baseOptions(): BaseLayoutProps { return { nav: { title: }, - links: [{ text: 'Docs', url: '/docs', active: 'nested-url' }], + links: [ + { text: 'Docs', url: '/docs', active: 'nested-url' }, + { text: 'Blog', url: '/blog', active: 'nested-url' }, + ], githubUrl, themeSwitch: { enabled: false }, }; diff --git a/apps/web/lib/shared.ts b/apps/web/lib/shared.ts index 5365e84..99a4457 100644 --- a/apps/web/lib/shared.ts +++ b/apps/web/lib/shared.ts @@ -24,6 +24,19 @@ export const gitConfig = { export const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}`; +// The byline on blog posts. +export const author = { name: 'Vaibhav Pathak', url: 'https://x.com/vkpdeveloper' }; + +/** A post's `YYYY-MM-DD` date as "Nov 6, 2026". UTC, so the server's time zone can't shift the day. */ +export function formatPostDate(date: string) { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', + }); +} + const getContentUrl = createGetUrl(docsContentRoute); export function getPageMarkdownUrl(page: { slugs: string[]; locale?: string }) { diff --git a/apps/web/lib/source.ts b/apps/web/lib/source.ts index d7f7276..89a4c9b 100644 --- a/apps/web/lib/source.ts +++ b/apps/web/lib/source.ts @@ -2,7 +2,8 @@ import { llms, loader } from 'fumadocs-core/source'; import { lucideIconsPlugin } from 'fumadocs-core/source/lucide-icons'; import { metaSchema, pageSchema } from 'fumadocs-core/source/schema'; import { applyMdxPreset } from 'fumadocs-mdx/config'; -import { defineDocs } from 'fumadocs-mdx/macro'; +import { defineCollections, defineDocs } from 'fumadocs-mdx/macro'; +import { z } from 'zod'; import { remarkTypeTableSearch } from './remark-type-table'; import { docsRoute, siteUrl } from './shared'; @@ -27,6 +28,17 @@ export const source = loader({ plugins: [lucideIconsPlugin()], }); +const blog = defineCollections({ + type: 'doc', + dir: 'content/blog', + schema: pageSchema.extend({ description: z.string(), date: z.iso.date() }), +}); + +/** Blog posts, newest first. A post's file name is its URL slug. */ +export const posts = blog.entries + .map((post) => ({ post, slug: post.info.path.replace(/\.mdx$/, '') })) + .sort((a, b) => b.post.date.localeCompare(a.post.date)); + export const docsLlms = llms(source, { renderPage: async (page) => `# ${page.data.title} (${siteUrl}${page.url}) diff --git a/apps/web/package.json b/apps/web/package.json index a44b390..efb0e94 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -18,7 +18,8 @@ "lucide-react": "1.67.0", "next": "16.4.5", "react": "19.3.1", - "react-dom": "19.3.1" + "react-dom": "19.3.1", + "zod": "4.6.7" }, "devDependencies": { "@tailwindcss/postcss": "4.3.4", diff --git a/bun.lock b/bun.lock index adf558f..d0c9ebe 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "next": "16.4.5", "react": "19.3.1", "react-dom": "19.3.1", + "zod": "4.6.7", }, "devDependencies": { "@tailwindcss/postcss": "4.3.4",