Skip to content

Calendar & Booking

  • Build a scheduling system with events, availability, recurring schedules, and booking management

Prerequisite

Complete the Get Started guide first.

A calendar and booking system is what any appointment-based business uses to manage schedules and let people reserve time. Think salons, clinics, consultancies, tutoring, coworking spaces, or any service where people book slots. The team manages availability, and events fill the calendar.

By the end of this example you will have:

  • Events — full CRUD for calendar events with attendees, locations, and recurrence
  • Availability — weekly schedule blocks defining when team members are bookable
  • Bookings — reservations with confirmation, cancellation, and status tracking
  • Calendar dashboard — today's schedule, upcoming bookings, and weekly overview
  • Realtime — all changes sync instantly across connected browsers

Database Schema and Permissions

Prompt

txt
We are building a calendar and booking app on top of this template. Create
the database schema and add the permissions we need.

Database (via Supabase MCP):

Create four tables:

1. `events` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   title (text, not null),
   description (text, nullable),
   location (text, nullable),
   start_time (timestamptz, not null),
   end_time (timestamptz, not null),
   all_day (boolean, default false, not null),
   recurrence_rule (text, nullable),
   color (text, default '#3b82f6', not null),
   organizer_id (uuid, references profiles(id) on delete set null, nullable),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

2. `event_attendees` table — id (uuid, default gen_random_uuid(), primary key),
   event_id (uuid, references events(id) on delete cascade, not null),
   user_id (uuid, references profiles(id) on delete cascade, nullable),
   name (text, not null),
   email (text, nullable),
   status (text, check status in ('pending', 'accepted', 'declined', 'tentative'),
   default 'pending', not null),
   created_at (timestamptz, default now()).
   Add a unique constraint on (event_id, user_id) where user_id is not null.

3. `availability_slots` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   user_id (uuid, references profiles(id) on delete cascade, not null),
   day_of_week (integer, check day_of_week between 0 and 6, not null),
   start_time (time, not null),
   end_time (time, not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

4. `bookings` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   event_id (uuid, references events(id) on delete set null, nullable),
   provider_id (uuid, references profiles(id) on delete set null, nullable),
   client_name (text, not null),
   client_email (text, nullable),
   client_phone (text, nullable),
   service (text, not null),
   start_time (timestamptz, not null),
   end_time (timestamptz, not null),
   status (text, check status in ('pending', 'confirmed', 'cancelled',
   'completed', 'no_show'), default 'pending', not null),
   notes (text, nullable),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

Enable RLS on all four tables. Create policies that use the existing
`is_team_member()` function to scope access. For `events`, `availability_slots`,
and `bookings`, the policy should check that the row's `team_id` matches a
team the user belongs to. For `event_attendees`, join through the `events`
table to check team membership.

All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks in the server
routes, not in RLS.

After creating the tables, add these permissions to `shared/permissions.ts`:

```
"events.view": ["owner", "admin", "member"],
"events.create": ["owner", "admin", "member"],
"events.update": ["owner", "admin", "member"],
"events.delete": ["owner", "admin"],
"availability.view": ["owner", "admin", "member"],
"availability.manage": ["owner", "admin", "member"],
"bookings.view": ["owner", "admin", "member"],
"bookings.create": ["owner", "admin", "member"],
"bookings.update": ["owner", "admin"],
"bookings.delete": ["owner", "admin"],
```

Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.

Adding plugins later?

If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.

Events

Prompt

txt
Build the events module — server routes and a full UI page.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/events` — uses `authUser(event, "events.view")`.
  Returns all events for the team within a date range. Required query params:
  `?start=` and `?end=` (ISO date strings). Include the organizer name and
  attendee count joined in. Order by start_time asc.

- `POST /api/teams/[teamId]/events` — uses
  `authUser(event, "events.create")`. Reads { title, description, location,
  start_time, end_time, all_day, recurrence_rule, color, organizer_id,
  attendees } from the body. Validates that title, start_time, and end_time
  are required, and that end_time is after start_time. Sets team_id from
  auth context and created_by from the authenticated user (user.sub). If
  attendees are provided (array of { user_id, name, email }), insert them
  into event_attendees after creating the event.

- `PATCH /api/teams/[teamId]/events/[eventId]` — uses
  `authUser(event, "events.update")`. Updates whichever fields are provided
  in the body. Validates that the event belongs to the team.

- `DELETE /api/teams/[teamId]/events/[eventId]` — uses
  `authUser(event, "events.delete")`. Validates that the event belongs to
  the team before deleting.

- `GET /api/teams/[teamId]/events/[eventId]/attendees` — uses
  `authUser(event, "events.view")`. Returns all attendees for the event
  with user profile info joined in where applicable.

- `PATCH /api/teams/[teamId]/events/[eventId]/attendees/[attendeeId]` —
  uses `authUser(event, "events.view")`. Updates the attendee's status
  (accepted, declined, tentative). Any team member can update their own
  attendance status.

UI:

Create `app/pages/calendar/index.vue` — the main calendar page as a
top-level route (not nested under `/dashboard`), wrapped in a
`UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Calendar",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Event"
  button wrapped in `CanAccess permission="events.create"`.

- `UDashboardToolbar` below the navbar: on the left, navigation buttons
  (Previous, Today, Next) to move between weeks/months. On the right,
  a view toggle using `UTabs` with `size="xs"`: Week, Month.

- **Week view** (default): a 7-column grid showing the days of the current
  week. Each day column has a header with the day name and date. Events
  are shown as colored blocks positioned by their start/end times. All-day
  events appear in a top row. Use the event's color property for the block
  background.

- **Month view**: a traditional month calendar grid. Each day cell shows
  up to 3 events as colored dots or short titles. Days with more events
  show a "+N more" indicator.

- Clicking an event opens a `USlideover` with the event details: title,
  time range, location, description, organizer, and an attendee list with
  their response status (color-coded badges). Include edit fields for all
  properties. Show an attendee management section: add attendees from team
  members or by name/email, remove attendees. Include a delete button
  wrapped in `CanAccess permission="events.delete"` with confirmation.

- "New Event" button opens a `UModal` with a form: title (required),
  start date/time (required — use `UPopover` with `UCalendar` for date,
  and a time input), end date/time (required), all-day toggle, location,
  description, color picker (preset colors), organizer (select from team
  members), attendees (multi-select from team members). Import
  `CalendarDate` from `@internationalized/date`. Use Zod for validation.

- After creating, editing, or deleting an event, refresh the calendar.

Sidebar navigation:

Add a "Calendar" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-calendar-bold-duotone`.

Availability & Bookings

Prompt

txt
Build the availability and booking modules — schedule management and
appointment booking.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/availability` — uses
  `authUser(event, "availability.view")`. Returns all availability slots
  for the team with user name joined in. Support optional query param
  `?user_id=` to filter by a specific team member.

- `PUT /api/teams/[teamId]/availability` — uses
  `authUser(event, "availability.manage")`. Replaces the calling user's
  entire availability schedule. Reads { slots } from the body where slots
  is an array of { day_of_week, start_time, end_time }. Validates that
  day_of_week is 0-6, times are valid, and end_time is after start_time.
  Deletes all existing slots for this user in this team and inserts the
  new ones. Sets team_id and user_id from auth context.

- `GET /api/teams/[teamId]/bookings` — uses
  `authUser(event, "bookings.view")`. Returns all bookings for the team
  with provider name joined in. Order by start_time desc. Support optional
  query params: `?status=` to filter by status, `?provider_id=` to filter
  by provider, `?start=` and `?end=` for date range filtering.

- `POST /api/teams/[teamId]/bookings` — uses
  `authUser(event, "bookings.create")`. Reads { provider_id, client_name,
  client_email, client_phone, service, start_time, end_time, notes } from
  the body. Validates that client_name, service, start_time, and end_time
  are required. Check that the booking doesn't overlap with existing
  confirmed bookings for the same provider — return 409 if it does. Sets
  team_id from auth context and created_by from the authenticated user.
  Optionally creates a linked event in the events table.

- `PATCH /api/teams/[teamId]/bookings/[bookingId]` — uses
  `authUser(event, "bookings.update")`. Updates whichever fields are
  provided. If status is changed to "confirmed" and there's no linked
  event, create one. If status is changed to "cancelled", delete the
  linked event if one exists. Validates that the booking belongs to the team.

- `DELETE /api/teams/[teamId]/bookings/[bookingId]` — uses
  `authUser(event, "bookings.delete")`. Validates that the booking belongs
  to the team. Deletes the linked event if one exists.

UI:

Create `app/pages/bookings/index.vue` — the bookings page as a top-level
route, wrapped in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Bookings",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Booking"
  button wrapped in `CanAccess permission="bookings.create"`.

- `UDashboardToolbar` below the navbar: on the left, show status counts
  (e.g. "3 Pending, 5 Confirmed, 2 Completed"). On the right, status
  filter using `USelect`: All, Pending, Confirmed, Cancelled, Completed,
  No Show.

- Booking list as a table with columns: client name, service, provider
  name (or "Unassigned"), date/time (formatted nicely showing start-end
  range), status badge (color-coded — pending = "warning", confirmed =
  "success", cancelled = "error", completed = "neutral", no_show = "error"),
  and client contact info.

- Each row has an inline status dropdown using `UDropdownMenu`.

- "New Booking" button opens a `UModal` with a form: client name (required),
  client email, client phone, service (required), provider (select from
  team members), start date/time (required — `UPopover` with `UCalendar`
  plus time input), end date/time (required), notes. Use Zod for validation.
  Show loading on submit. Show error toast if there's a time conflict.

- Clicking a booking row opens a `USlideover` for editing all fields.
  Include a delete button wrapped in
  `CanAccess permission="bookings.delete"` with confirmation.

Add an "Availability" section to each team member's settings or create
`app/pages/settings/availability.vue`:

- Show the current user's weekly availability as a 7-row grid (Mon-Sun).
  Each row shows the day name and time slots as editable ranges.
- "Add Slot" button on each day to add a new time range.
- "Remove" button on each slot to delete it.
- "Save" button that sends the entire schedule via PUT.
- Use time inputs for start and end times.

Sidebar navigation:

Add a "Calendar" link and a "Bookings" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`. Calendar between "Dashboard"
and "Bookings", Bookings between "Calendar" and "Settings".
Use icons `i-solar-calendar-bold-duotone` for Calendar and
`i-solar-notebook-bookmark-bold-duotone` for Bookings.

Dashboard and Realtime

Prompt 1 — Dashboard

txt
Replace the placeholder dashboard with real calendar and booking stats.

Server route:

Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:

- `events_today` — count of events happening today for the team
- `bookings_today` — count of confirmed bookings for today
- `pending_bookings` — count of bookings with status "pending"
- `completed_this_week` — count of bookings with status "completed" where
  start_time is in the current calendar week
- `upcoming_events` — the next 5 events ordered by start_time asc, with
  organizer name and attendee count joined in
- `upcoming_bookings` — the next 5 confirmed bookings ordered by start_time
  asc, with provider name and client name
- `team_schedule_today` — array of { member_name, events_count,
  bookings_count } for each team member with activity today

All queries are scoped to the team's team_id.

UI:

Update `app/pages/dashboard/index.vue` to show:

- A row of four stat cards at the top using a grid layout. Each card shows
  an icon, a label, and the value. Cards: "Events Today" (events_today),
  "Bookings Today" (bookings_today), "Pending Bookings" (pending_bookings),
  "Completed This Week" (completed_this_week).
  Use `USkeleton` placeholders while loading.

- Below the stats, a two-column layout:
  - Left column: "Today's Schedule" — a combined timeline of today's events
    and bookings, sorted by start time. Events show title, time, location.
    Bookings show client name, service, provider, time. Color-code by type.
    Show an empty state if nothing is scheduled today.
  - Right column: "Upcoming" — two sub-sections: upcoming events (next 5)
    and upcoming bookings (next 5), each showing title/client, date/time,
    and organizer/provider. Show empty states if none.

Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.

Prompt 2 — Realtime

txt
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.

Database migration (via Supabase MCP):

Enable realtime publication and full replica identity for the new tables:

```sql
ALTER PUBLICATION supabase_realtime ADD TABLE events, event_attendees, bookings, availability_slots;
ALTER TABLE events REPLICA IDENTITY FULL;
ALTER TABLE event_attendees REPLICA IDENTITY FULL;
ALTER TABLE bookings REPLICA IDENTITY FULL;
ALTER TABLE availability_slots REPLICA IDENTITY FULL;
```

Composables:

1. Create `app/composables/useRealtimeEvents.ts` — takes a
   `Ref<Event[]>` (or whatever the event array type is). Registers on
   the `events` table via the existing `useRealtime` composable's
   `onTable` function. Handles INSERT (dedup against existing items by id),
   UPDATE (replace the matching item), and DELETE (remove by id). Always
   assign a new array to `.value` — do not mutate in place, or Vue will
   not re-render.

2. Create `app/composables/useRealtimeBookings.ts` — same pattern but for
   the `bookings` table.

Update `app/composables/useRealtime.ts` to subscribe to `events`,
`event_attendees`, `bookings`, and `availability_slots` tables in addition
to the existing subscriptions.

Integration:

- In `app/pages/calendar/index.vue`, call
  `useRealtimeEvents(events)` where `events` is the ref holding the
  event list.
- In `app/pages/bookings/index.vue`, call `useRealtimeBookings(bookings)`
  where `bookings` is the ref holding the booking list.
- The dashboard stats page should refetch stats when any realtime event
  fires on events or bookings. Use `onTable("events", refetchStats)` and
  `onTable("bookings", refetchStats)` directly in the dashboard page.

What You Built

Starting from a template that handled auth, teams, roles, and permissions, you added:

  1. Events — a full calendar with week/month views, attendees, and color-coding
  2. Availability — weekly schedule management for each team member
  3. Bookings — appointment scheduling with conflict detection and status tracking
  4. Dashboard — today's schedule, upcoming bookings, and team workload
  5. Realtime — all changes sync instantly across connected browsers

Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.

What's Next

  • API Keys & External API — let external booking widgets create bookings or check availability programmatically
  • AI Chat — a chatbot that answers questions like "What's my schedule today?" or "When is the next available slot?"