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
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
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
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
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
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:
- Events — a full calendar with week/month views, attendees, and color-coding
- Availability — weekly schedule management for each team member
- Bookings — appointment scheduling with conflict detection and status tracking
- Dashboard — today's schedule, upcoming bookings, and team workload
- 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?"