Examples
Calendar & Booking
Build a scheduling system with events, availability, recurring schedules, and booking management
Prerequisite: Complete the Get Started guide first.
To build this feature, copy the first prompt, paste it into Claude Code (or Codex, or any AI coding tool), let it finish, then run the next prompt.
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
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.
Only create a SELECT policy for team members — follow the
`announcements_team_member_read` pattern in
`supabase/migrations/00001_initial_schema.sql`. Writes go through
service-role server routes, so no insert/update/delete RLS policies are
needed. Permission checks live in server routes via `authUser(event, "key")`.
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"],
```
Also wire the new tables into the baked-in AI chat and activity log per
CLAUDE.md conventions:
- Add `COMMENT ON COLUMN` on every non-obvious column — one short technical
sentence. Mention format or business rules the DB does not enforce.
- `select enable_activity_log('<table>');` for each mutation-bearing table.
- Grant chat read access on each team-scoped table:
```
grant select on <table> to chat_reader;
create policy "<table>_select_chat" on <table>
for select to chat_reader using (team_id = current_chat_team());
```
Skip tables without a `team_id` column (scope them through a parent).
- Register each table in `tablePermissions` in `shared/permissions.ts`
using the permission keys above.
- Add filter entries in `app/components/activity/List.vue` (`tableItems`)
for each new table.
Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.
Adding the public API later? If you plan to add the Public API plugin later, its page will guide you through adding the required permissions. You don't need to add them now.
Events
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/app/calendar/index.vue` — the main calendar page as a
top-level route (under `/app`, sibling of other features), 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
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/app/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/app/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 "Bookings" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Calendar" (already
added in the previous prompt) and "Settings". Use the icon
`i-solar-notebook-bookmark-bold-duotone`.
Dashboard and Realtime
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/app/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.
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;
```
Update `app/composables/useRealtime.ts`:
1. Add `"events"`, `"event_attendees"`, `"bookings"`, and
`"availability_slots"` to the `RealtimeTable` union type.
2. In the `setup()` function, add `.on("postgres_changes", ...)` handlers
for each new table, filtered by `team_id=eq.${teamId}` where the table
has a team_id column.
Integration — use `onTableDebounced` from `useRealtime()` inline in each
page. Do NOT create separate `useRealtimeX` composable files:
- In `app/pages/app/calendar/index.vue`:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced(["events", "event_attendees"], () => refreshEvents())`
- In `app/pages/app/bookings/index.vue`:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced(["bookings", "availability_slots"], () => refreshBookings())`
- In the dashboard stats page:
`const { onTableDebounced } = useRealtime()`
`onTableDebounced(["events", "bookings"], () => refreshStats())`
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
- Public API — let external booking widgets create bookings or check availability programmatically
- AI Chat — the baked-in assistant is already wired to your new tables via
tablePermissions. Try it with "What's my schedule today?" or "When is the next available slot?"