Calendar
Calendars and events with RFC 5545 recurrence, per-occurrence edits, attendees, iCalendar import and export, and an iCal feed through a share link.
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 uses, so
both agree on every date. Editing one occurrence writes an override row, and
editing "this and following" splits the series in two.
better-supabase sql add calendarimport { 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
| 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
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
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.
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
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.
const link = await shareLinks
.create(organizationId, "calendar", calendarId, "view")
.orThrow();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
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.
"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
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.
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
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.
Last updated on
Commerce
Money in bigint minor units, and versioned payment and tax providers with Stripe adapters.
Bookings
Bookable resources with weekly hours, services with buffers, open slots computed in SQL, holds that expire, double bookings refused by an exclusion constraint, and public booking through a share link.