Build a Calendly-style scheduling app in Lovable where hosts define availability windows and guests self-book appointments. All times are stored as UTC in Supabase, timezone conversion happens at render time, and a unique database constraint prevents double-bookings at the database level. The guest-facing page requires no login. Build time is about 2 hours.
What you're building
The scheduling app has two sides. The host side (requires auth) is where someone sets up their meeting type: a name, duration, description, and a weekly availability schedule. Availability is stored as rows in an availability_slots table — each row represents a recurring window on a specific day of the week (e.g. Tuesday 10:00–12:00). These are stored as plain time values in the host's chosen timezone.
The guest side is a public page at /schedule/:slug. The guest picks a date on the Calendar component. The app then generates the list of valid time slots for that date by reading the host's availability for that weekday and subtracting any existing appointments on that day. Slots that are already booked appear disabled or are simply not shown.
All appointment timestamps are converted to UTC before being saved to Supabase. The host dashboard displays times in the host's timezone. The guest confirmation shows times in the guest's browser timezone. The unique constraint on appointments(host_id, starts_at) is the final safety net — if a race condition occurs and two guests try to book the same slot simultaneously, the database rejects the second insert.
Final result
A complete scheduling app with public booking pages, timezone-correct time display, and database-enforced double-booking prevention.
Tech stack
Prerequisites
- Lovable Pro account for multi-file generation
- Supabase project with Auth enabled for host accounts
- Supabase credentials saved to Cloud tab → Secrets
- Basic familiarity with IANA timezone names (like 'America/New_York')
- Two browser profiles or incognito windows for testing the guest and host sides
Build steps
Create the scheduling schema with the double-booking constraint
Prompt Lovable to create the database schema. The critical piece is the unique constraint on appointments that prevents double-bookings at the database level regardless of what happens in the application logic.
1Create a Supabase schema for a scheduling app.23Tables:4- hosts: id (uuid pk, references auth.users), name (text), bio (text), slug (text unique), timezone (text, IANA e.g. 'America/Chicago'), avatar_url (text), created_at5- meeting_types: id (uuid pk), host_id (references hosts), name (text), duration_minutes (int default 30), description (text), is_active (bool default true), created_at6- availability_slots: id (uuid pk), host_id (references hosts), day_of_week (int, 0=Sun to 6=Sat), start_time (time), end_time (time), is_active (bool default true)7- appointments: id (uuid pk), host_id (references hosts), meeting_type_id (references meeting_types), guest_name (text not null), guest_email (text not null), starts_at (timestamptz not null), ends_at (timestamptz not null), guest_notes (text), status (text default 'confirmed' check: confirmed|cancelled), created_at89Critical constraint:10ALTER TABLE appointments ADD CONSTRAINT no_double_booking UNIQUE (host_id, starts_at) DEFERRABLE INITIALLY IMMEDIATE;1112Note: filter WHERE status != 'cancelled' via a partial unique index instead:13CREATE UNIQUE INDEX appointments_no_double_booking ON appointments (host_id, starts_at) WHERE status = 'confirmed';1415RLS:16- hosts: SELECT public; INSERT/UPDATE where id = auth.uid()17- meeting_types: SELECT public; INSERT/UPDATE/DELETE where host_id = auth.uid()18- availability_slots: SELECT public; INSERT/UPDATE/DELETE where host_id = auth.uid()19- appointments: SELECT where host_id = auth.uid(); INSERT public (any guest can book)2021Create an index: CREATE INDEX idx_appointments_host_date ON appointments (host_id, starts_at);Pro tip: Use a partial unique index (WHERE status = 'confirmed') rather than a full unique constraint. This way a cancelled appointment slot can be rebooked — you are only preventing two confirmed appointments at the same time, not all historical records.
Expected result: All tables are created. The partial unique index exists on appointments. RLS is enabled on all tables. The public SELECT on hosts, meeting_types, and availability_slots allows the guest booking page to work without auth.
Build the host onboarding and meeting type setup
Ask Lovable to build the host setup flow. A new host fills in their profile, gets a slug, and creates at least one meeting type before sharing their link.
1Build a host setup flow at src/pages/HostSetup.tsx (requires auth).23Step 1 — Profile:4- Form fields: name, bio (Textarea), slug (Input, validate URL-safe with regex /^[a-z0-9-]+$/), timezone (Select with 15 common IANA options), avatar upload to Supabase Storage5- Upsert into hosts table on save6- Show preview URL: /schedule/{slug}78Step 2 — Meeting type:9- Form fields: name (e.g. '30-minute intro call'), duration_minutes (Select: 15/30/45/60), description (Textarea)10- Insert into meeting_types table11- Show success and 'Share your link' button1213Step 3 — Availability:14- Render 7 rows (Sunday through Saturday)15- Each row: day name, Switch (enabled/disabled), start time Select (30-min intervals), end time Select16- When Switch is disabled, hide the time selectors17- On save, delete existing availability_slots for this host and re-insert current configuration18- Show 'Your schedule is live' confirmation1920Also build a combined settings page at src/pages/HostSettings.tsx that shows all three sections on one page for returning hosts updating their setup.Pro tip: When saving availability, delete-then-reinsert is simpler and safer than upsert because the user may have removed a day. Ask Lovable to wrap the delete and insert in a Supabase transaction: supabase.rpc('update_availability', { slots: [...] }) where the RPC function handles the delete and insert atomically.
Expected result: A new host can complete all three setup steps. Their availability is saved. Visiting /schedule/their-slug shows the public booking page.
Build the guest-facing scheduling page
Ask Lovable to create the public scheduling page. The guest picks a date, picks a time, fills in their details, and gets a confirmation. All times are shown in the guest's detected browser timezone.
1Build a public scheduling page at src/pages/SchedulePage.tsx with route /schedule/:slug.23On load:4- Fetch host by slug (name, bio, avatar_url, timezone)5- Fetch meeting types for this host (show Select if multiple, auto-select if one)6- Fetch availability_slots for this host7- Detect guest timezone: const guestTz = Intl.DateTimeFormat().resolvedOptions().timeZone89Date selection:10- Render shadcn/ui Calendar11- Disable past dates12- Disable dates where no availability_slot exists for that day_of_week13- On date select: compute available time slots1415Slot computation (client-side for this simpler version):161. Find availability_slots row matching selected date's day_of_week172. Generate slot start times from start_time to end_time with duration_minutes increments183. Fetch appointments WHERE host_id = ? AND starts_at::date = selectedDate AND status = 'confirmed'194. Remove slots that overlap any existing appointment205. Convert remaining UTC slot times to guestTz for display2122Time slot display:23- Show slots as clickable Cards or a vertical list of Buttons24- Each shows the time in guest timezone (e.g. '2:30 PM')25- Below the list show: 'Times shown in [guestTz]'2627Booking form (shown after slot selected):28- guest_name (Input, required)29- guest_email (Input, email, required)30- guest_notes (Textarea, optional, 'Anything I should know?')31- Submit inserts into appointments. On unique constraint error (409/23505), show 'That slot was just booked. Please choose another.'3233Confirmation state:34- Show appointment details35- 'Add to calendar' link (.ics download)36- Reference number (first 8 chars of appointment UUID, uppercase)Expected result: Visiting /schedule/:slug shows the host profile and Calendar. Selecting a date shows available slots. Completing the form creates the appointment and shows the confirmation. Trying to book an already-taken slot shows an error.
Build the host dashboard
Ask Lovable to build the host's private dashboard showing upcoming and past appointments. Hosts can cancel appointments and see guest contact details.
1Build a host appointments dashboard at src/pages/HostDashboard.tsx (requires auth).23Requirements:4- Fetch all appointments for auth.uid() ordered by starts_at5- Split into Tabs: 'Upcoming' (starts_at > now(), status = confirmed) and 'Past'6- Each appointment as a Card:7 - Guest name (bold) and email (with mailto: link)8 - Start time formatted in host's timezone: 'Tuesday, June 10 at 2:30 PM EDT'9 - Meeting type name10 - Duration as a Badge11 - Guest notes (if present, shown in a lighter text block)12 - Reference ID (first 8 chars of id, monospace)13 - For upcoming: 'Cancel' Button with AlertDialog confirmation1415Cancellation:16- UPDATE appointments SET status = 'cancelled' WHERE id = ?17- Optimistic update: remove card from Upcoming list immediately1819Header area:20- 'Copy booking link' Button that copies yourdomain.com/schedule/{slug} to clipboard21- Stats row: total bookings this month, upcoming this week (simple count queries)Pro tip: Add Supabase Realtime subscription on the appointments table for the current host. When a new confirmed appointment is inserted, show a toast 'New booking: [guest_name] at [time]'. This is more useful than email for hosts who keep the dashboard open.
Expected result: The dashboard shows appointments in two tabs. Stats show booking counts. Cancelling an appointment removes it from the upcoming list. The copy booking link button works.
Complete code
1import { parse, addMinutes, format, isAfter, isBefore, parseISO } from 'date-fns'23type AvailabilitySlot = {4 start_time: string // 'HH:mm'5 end_time: string // 'HH:mm'6}78type ExistingAppointment = {9 starts_at: string // ISO 8601 UTC10 ends_at: string // ISO 8601 UTC11}1213type TimeSlot = {14 label: string // '2:30 PM'15 isoUtc: string // UTC ISO string for storage16}1718export function computeAvailableSlots(19 date: Date,20 availability: AvailabilitySlot,21 durationMinutes: number,22 existingAppointments: ExistingAppointment[]23): TimeSlot[] {24 const dateStr = format(date, 'yyyy-MM-dd')2526 // Parse availability times as local time on the given date27 const windowStart = parse(28 `${dateStr} ${availability.start_time}`,29 'yyyy-MM-dd HH:mm',30 new Date()31 )32 const windowEnd = parse(33 `${dateStr} ${availability.end_time}`,34 'yyyy-MM-dd HH:mm',35 new Date()36 )3738 const slots: TimeSlot[] = []39 let cursor = windowStart40 const now = new Date()4142 while (isBefore(cursor, windowEnd)) {43 const slotEnd = addMinutes(cursor, durationMinutes)4445 // Skip slots in the past46 if (isAfter(cursor, now)) {47 // Check for conflicts with existing appointments48 const hasConflict = existingAppointments.some((appt) => {49 const apptStart = parseISO(appt.starts_at)50 const apptEnd = parseISO(appt.ends_at)51 return cursor < apptEnd && slotEnd > apptStart52 })5354 if (!hasConflict) {55 slots.push({56 label: format(cursor, 'h:mm a'),57 isoUtc: cursor.toISOString(),58 })59 }60 }6162 cursor = addMinutes(cursor, durationMinutes)63 }6465 return slots66}Customization ideas
Multiple meeting types on one scheduling page
Show a Select at the top of the scheduling page listing all active meeting_types for the host (e.g. '15-min quick call', '60-min strategy session'). When the guest selects a type, re-compute slots using that type's duration_minutes. This lets one host URL serve multiple appointment lengths.
Intake form questions per meeting type
Add a meeting_type_questions table with meeting_type_id, question_text, and is_required. The booking form dynamically renders these questions as Inputs or Textareas after the guest_notes field. Answers are stored as a JSONB column in appointments.
iCal and Google Calendar integration
Generate a .ics file from the appointment data using the VEVENT format and trigger a browser download from the confirmation page. For Google Calendar, construct a calendar.google.com/calendar/r/eventedit URL with date/time query parameters and open it in a new tab as a 'Add to Google Calendar' button.
Host unavailability overrides
Add a host_unavailability table for one-off dates or date ranges when the host is not available despite their normal schedule (vacations, holidays). The slot computation step checks this table and returns an empty slot list for any date that falls in an unavailability range.
Team round-robin scheduling
Add a teams table and team_members join table. A team booking page at /schedule/team/:slug distributes bookings round-robin across team members who have the requested time slot available. The current member index is tracked in a team_round_robin table and incremented on each successful booking.
Common pitfalls
Pitfall: Computing slots entirely client-side without any server validation
How to avoid: The partial unique index on appointments is your server-side safety net. Always handle the unique constraint violation (PostgreSQL error code 23505) on the client with a friendly error message and a prompt to re-select a slot.
Pitfall: Disabling the wrong days on the Calendar component
How to avoid: Initialize the Calendar with all dates disabled and only enable dates once the availability_slots query resolves. Use a loading skeleton on the Calendar while the query is in-flight: disabled={isLoading ? () => true : (date) => !hasAvailability(date)}.
Pitfall: Displaying times without clearly labeling the timezone
How to avoid: Always append the timezone abbreviation to every displayed time. Use Intl.DateTimeFormat with timeZoneName: 'short' to get the abbreviation. Show both host timezone and guest timezone on the confirmation screen.
Pitfall: Allowing guests to book slots that are fewer than a minimum lead time away
How to avoid: Add a minimum_notice_hours column to meeting_types. In the slot computation, filter out any slots where the slot start time is less than minimum_notice_hours from now().
Best practices
- Use a partial unique index WHERE status = 'confirmed' on appointments(host_id, starts_at) as the database-level double-booking guard. This is the most reliable prevention mechanism regardless of application logic bugs.
- Store all appointment timestamps as UTC timestamptz. Never store local time with an offset — use proper IANA timezone names separately for display purposes.
- Make the guest booking page fully public (no auth) and use Supabase anon key for all reads. Only authenticated hosts can read their own appointments list.
- Gracefully handle the 23505 unique constraint error from Supabase on the booking form submit. Show a human-readable message and automatically refetch the slot list to show updated availability.
- Add an index on appointments(host_id, starts_at) to keep the 'fetch existing appointments for a date' query fast as appointment volume grows.
- Generate a human-readable reference number for each appointment (first 8 chars of UUID, uppercased) to use in confirmations and cancellation requests rather than exposing the full UUID.
- Validate the slug on the host settings page with a real-time availability check — query Supabase on blur to show 'Slug already taken' before the form is submitted.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a scheduling app where a host sets availability as recurring weekly time windows (stored as plain HH:mm times in their local timezone) and guests book 30-minute appointments. Help me write a TypeScript function that takes a target date, an availability window (start_time, end_time as strings), a duration in minutes, and an array of existing appointment objects with starts_at and ends_at as UTC ISO strings. The function should return an array of available slot objects with isoUtc (for storage) and label (formatted time in the guest's detected browser timezone). Show me how to handle the timezone conversion correctly using date-fns.
Add an 'Upcoming bookings' section to my public scheduling page at /schedule/:slug. Below the calendar and time slots, show the next 3 confirmed appointments for this host as a simple list showing the date and time (no guest names for privacy). This acts as social proof that the host is active and in demand. Fetch this data from appointments WHERE host_id = ? AND starts_at > now() AND status = 'confirmed' ORDER BY starts_at LIMIT 3. Show only the formatted date and time, not any guest information.
In Supabase, create a PostgreSQL function cancel_appointment(p_appointment_id uuid, p_guest_email text) that: (1) fetches the appointment by ID, (2) checks that the guest_email matches the stored email or that the caller is the host (auth.uid() = host_id), (3) checks that starts_at > now() + interval '1 hour' (no same-hour cancellations), (4) sets status = 'cancelled'. Make it SECURITY DEFINER callable by the anon role so guests can cancel without auth using their email as verification. Return a boolean success value.
Frequently asked questions
Does Lovable support public pages that do not require login?
Yes. Any route in your Lovable app can be made public. The guest scheduling page fetches data using the Supabase anon key, which only accesses data permitted by your RLS policies. Set SELECT policies on the hosts, meeting_types, availability_slots, and the bookings INSERT policy to public (anon role), and the page works for anyone without a login.
What is the difference between this and the booking-platform build?
This scheduling-app build focuses on the core double-booking constraint, UTC storage pattern, and a simple client-side slot computation approach. The booking-platform build adds a Supabase Edge Function for server-side slot computation (better for large scale), buffer time between bookings, and more complex service type configuration. Start with this build if you want the simpler version.
How does the partial unique index prevent double-bookings when two guests submit simultaneously?
PostgreSQL processes concurrent INSERTs serially for rows that would violate a unique constraint. The first INSERT succeeds; the second receives a unique constraint violation error (code 23505). Supabase surfaces this as an error response to the client. Your app catches this error and tells the guest to choose a different slot — the database is the final arbiter, not the application logic.
Can I add a buffer time between appointments so the host has a break?
Yes. Add a buffer_minutes column to meeting_types. In the slot computation, when checking for conflicts, treat each existing appointment as occupying starts_at to ends_at + buffer_minutes. A slot is unavailable if it starts before any existing appointment's buffered end time.
How do I let the host set different availability for each week, not just recurring?
Add a specific_date column to a separate availability_overrides table. When computing slots, first check if an override exists for the specific date and use that instead of the weekly template. This supports holiday exceptions and one-off schedule changes.
Is it possible for guests to reschedule instead of just cancelling?
Yes. Add a rescheduled_from_id column (references appointments) to appointments. The reschedule flow cancels the original appointment and creates a new one with rescheduled_from_id set to the original. You can then display reschedule history in the host dashboard and use the confirmation_code from the original booking to identify the rescheduled appointment.
Can I use this for team scheduling where any available team member takes the booking?
The current schema supports single-host scheduling. For round-robin team scheduling, you would need a teams table, team_members join table, and logic to find any team member available at the requested time. The booking is then assigned to the next team member in rotation. This is a significant extension of the current build.
Where can I get help if I need features beyond what this guide covers?
RapidDev builds production-grade Lovable applications. If you need complex scheduling logic, team features, payment integration, or reminder email workflows, reach out to discuss a scoped build.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation