# Calendar

> Calendars and events with RFC 5545 recurrence, per-occurrence edits, attendees, iCalendar import and export, and an iCal feed through a share link.

Source: https://bettersupabase.com/docs/blocks/calendar

`calendar` stores calendars and their events in Postgres. A recurring event is
one row with an RFC 5545 `RRULE`; `occurrences()` expands it in TypeScript with
the same `expandRule` the [reminders block](/docs/blocks/reminders) uses, so
both agree on every date. Editing one occurrence writes an override row, and
editing "this and following" splits the series in two.

```bash
better-supabase sql add calendar
```

```ts
import { createCalendar } from "better-supabase/blocks/calendar";
```

The module requires `tenant` and `access`. With `share-links` it serves an
iCal feed, with `reminders` each event gets a recurrence rule that reminders
can attach to, and with `outbox` its events are recorded.

## Tables [#tables]

| Table                | Holds                                                                                                                        |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `calendars`          | A named calendar in an organization, with an IANA `tz` and an optional owner subject (`owner_type`, `owner_id`)              |
| `calendar_events`    | `starts_at`, `ends_at` (exclusive), `all_day`, `tz`, `status`, `rrule`, `exdates`, an optional subject, and override columns |
| `calendar_attendees` | An email per event, with an optional user id, a `role` and a `response`                                                      |

An override has `recurrence_id` (the series row) and `original_start` (the
occurrence it replaces) and shares the series' `uid`, as `RECURRENCE-ID` does
in iCalendar. Members read through the tables' policies; every write goes
through a function that checks `calendar.write`, or `calendar.manage` to
create and delete calendars. Deleting a user keeps the events they created.

## Events and occurrences [#events-and-occurrences]

```ts
const calendar = createCalendar({ transport });

const { id: calendarId } = await calendar
  .createCalendar(organizationId, "Team", { tz: "Europe/Amsterdam" })
  .orThrow();

await calendar
  .save(calendarId, {
    title: "Stand-up",
    startsAt: Temporal.Instant.from("2026-03-23T08:00:00Z"),
    endsAt: Temporal.Instant.from("2026-03-23T08:15:00Z"),
    tz: "Europe/Amsterdam",
    rrule: "FREQ=WEEKLY;BYDAY=MO",
  })
  .orThrow();

const week = await calendar
  .occurrences([calendarId], {
    from: Temporal.Instant.from("2026-03-30T00:00:00Z"),
    until: Temporal.Instant.from("2026-04-06T00:00:00Z"),
  })
  .orThrow();
```

A series follows its `tz`: the stand-up stays at 09:00 in Amsterdam across
the daylight saving change, so its UTC time moves. Each occurrence has a
stable `key` (the series id and its original start), its `start` and `end`,
and the `event` row that applies, which is the override when there is one.

| Method                                | Does                                                                                     |
| ------------------------------------- | ---------------------------------------------------------------------------------------- |
| `save(calendarId, input)`             | Inserts an event, or updates the one with `input.id`; keys left out keep their value     |
| `events(calendarIds, range)`          | The rows that can fall in the range: single events, series and overrides                 |
| `occurrences(calendarIds, range)`     | The occurrences in the range, with EXDATEs removed and overrides applied                 |
| `editOccurrence(occurrence, changes)` | Changes one occurrence: an override for a series, an update for a single event           |
| `editFollowing(occurrence, changes)`  | Changes this occurrence and every later one, splitting the series unless it is the first |
| `cancelOccurrence(occurrence)`        | Adds an `EXDATE` and drops the occurrence's override, or deletes a single event          |
| `delete(eventId)`                     | Deletes an event with its overrides                                                      |
| `setAttendee`, `removeAttendee`       | Invites or removes an attendee by email                                                  |
| `respond(eventId, response)`          | The signed-in user answers: `accepted`, `declined`, `tentative` or `needs-action`        |

`editFollowing` ends the old series the second before the occurrence (its
`COUNT` becomes an `UNTIL`) and starts a new series there with the remaining
count and a new uid. EXDATEs and overrides after the split move to the new
series. `splitRule(rrule, dtstart, at)` computes both rules if you need them
yourself.

## iCalendar [#icalendar]

`importIcs` parses an RFC 5545 file and upserts its events by `UID`, and
`RECURRENCE-ID` events become overrides of their series. Importing the same
file again updates the events instead of duplicating them. `exportIcs` writes
the calendar back, with a `VTIMEZONE` for every zone its events use.

```ts
const summary = await calendar.importIcs(calendarId, text).orThrow();
// { created: 12, updated: 0, skipped: 1 }

const ics = await calendar
  .exportIcs({ id: calendarId, name: "Team", tz: "Europe/Amsterdam" })
  .orThrow();
```

The parser and writer are in the package (`parseIcs`, `toIcs`): they unfold
and fold lines at 75 octets, escape `TEXT`, and read `TZID`, UTC, floating and
all-day times, `DURATION`, `EXDATE`, `STATUS` and `ATTENDEE`. `VALARM` and
`VTODO` are skipped.

## iCal feed [#ical-feed]

With `share-links` installed, a share link to a calendar (`subjectType:
"calendar"`) works as a subscription URL. `calendar_feed(token)` is open to
`anon`, hashes the token before the lookup and returns nothing for a revoked,
expired or unknown token, or a link whose organization is not the calendar's.

```ts
const link = await shareLinks
  .create(organizationId, "calendar", calendarId, "view")
  .orThrow();
```

```ts title="app/calendars/[token]/route.ts"
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ token: string }> },
) {
  const { token } = await params;
  const ics = await calendar.feed(token).orThrow();
  if (ics === null) return new Response("Not found", { status: 404 });
  return new Response(ics, {
    headers: { "content-type": "text/calendar; charset=utf-8" },
  });
}
```

## React [#react]

`useCalendar` loads the occurrences of a visible range and returns them in the
shape an event calendar renders. The hook is typed structurally, so it needs
no UI library.

```tsx
"use client";

import { useCalendar } from "better-supabase/blocks/calendar/react";

const { events, loading, editOccurrence, editFollowing, cancelOccurrence } =
  useCalendar({ calendarIds, range: { start, end } });
```

Each event's `id` is the occurrence key and its `data` is the occurrence, so
a drop or a resize maps back with `fromEventCalendarUpdate`.
`toEventCalendarResources` turns calendars into resources for a resource view;
by default each event's resource is its calendar, and `resourceOf` picks
another one, such as the event's subject.

## Hooks [#hooks]

`before_calendar_event_save(tenant, calendar, item)` runs before an insert or
update and can raise to refuse it; `after_calendar_event_save(event)` runs
after it. See [block hooks](/docs/extending/blocks#hooks).

## Errors [#errors]

| Hint                          | When                                                                                           |
| ----------------------------- | ---------------------------------------------------------------------------------------------- |
| `CALENDAR_FORBIDDEN`          | The caller lacks `calendar.write` or `calendar.manage`                                         |
| `CALENDAR_NOT_FOUND`          | The calendar does not exist                                                                    |
| `CALENDAR_EVENT_NOT_FOUND`    | The event does not exist in that calendar                                                      |
| `CALENDAR_EVENT_INVALID`      | A missing start or end, an end before the start, a bad status, or an override without a series |
| `CALENDAR_TZ_INVALID`         | `tz` is not an IANA time zone                                                                  |
| `CALENDAR_SPLIT_INVALID`      | The split is at or before the series' first occurrence                                         |
| `CALENDAR_ATTENDEE_NOT_FOUND` | The caller is not invited to the event                                                         |
| `CALENDAR_RESPONSE_INVALID`   | The response is not one of the four values                                                     |

## With ReUI [#with-reui]

ReUI's Event Calendar renders `useCalendar`'s `events` directly: month, week,
day and agenda views, drag to move and resize, and resource views from
`toEventCalendarResources`. Install it with
`npx shadcn@latest add @reui/event-calendar`; the free examples
`c-event-calendar-1` to `c-event-calendar-5` show each view. The Pro blocks
in the `application/event-calendar` category build full scheduling screens on
it; their source is licensed per user, so copy them into your app from
[reui.io](https://reui.io).