# How to Build a Scheduling App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

### 1. 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.

```
Create a Supabase schema for a scheduling app.

Tables:
- 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_at
- meeting_types: id (uuid pk), host_id (references hosts), name (text), duration_minutes (int default 30), description (text), is_active (bool default true), created_at
- 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)
- 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_at

Critical constraint:
ALTER TABLE appointments ADD CONSTRAINT no_double_booking UNIQUE (host_id, starts_at) DEFERRABLE INITIALLY IMMEDIATE;

Note: filter WHERE status != 'cancelled' via a partial unique index instead:
CREATE UNIQUE INDEX appointments_no_double_booking ON appointments (host_id, starts_at) WHERE status = 'confirmed';

RLS:
- hosts: SELECT public; INSERT/UPDATE where id = auth.uid()
- meeting_types: SELECT public; INSERT/UPDATE/DELETE where host_id = auth.uid()
- availability_slots: SELECT public; INSERT/UPDATE/DELETE where host_id = auth.uid()
- appointments: SELECT where host_id = auth.uid(); INSERT public (any guest can book)

Create 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.

### 2. 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.

```
Build a host setup flow at src/pages/HostSetup.tsx (requires auth).

Step 1 — Profile:
- 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 Storage
- Upsert into hosts table on save
- Show preview URL: /schedule/{slug}

Step 2 — Meeting type:
- Form fields: name (e.g. '30-minute intro call'), duration_minutes (Select: 15/30/45/60), description (Textarea)
- Insert into meeting_types table
- Show success and 'Share your link' button

Step 3 — Availability:
- Render 7 rows (Sunday through Saturday)
- Each row: day name, Switch (enabled/disabled), start time Select (30-min intervals), end time Select
- When Switch is disabled, hide the time selectors
- On save, delete existing availability_slots for this host and re-insert current configuration
- Show 'Your schedule is live' confirmation

Also 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.

### 3. 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.

```
Build a public scheduling page at src/pages/SchedulePage.tsx with route /schedule/:slug.

On load:
- Fetch host by slug (name, bio, avatar_url, timezone)
- Fetch meeting types for this host (show Select if multiple, auto-select if one)
- Fetch availability_slots for this host
- Detect guest timezone: const guestTz = Intl.DateTimeFormat().resolvedOptions().timeZone

Date selection:
- Render shadcn/ui Calendar
- Disable past dates
- Disable dates where no availability_slot exists for that day_of_week
- On date select: compute available time slots

Slot computation (client-side for this simpler version):
1. Find availability_slots row matching selected date's day_of_week
2. Generate slot start times from start_time to end_time with duration_minutes increments
3. Fetch appointments WHERE host_id = ? AND starts_at::date = selectedDate AND status = 'confirmed'
4. Remove slots that overlap any existing appointment
5. Convert remaining UTC slot times to guestTz for display

Time slot display:
- Show slots as clickable Cards or a vertical list of Buttons
- Each shows the time in guest timezone (e.g. '2:30 PM')
- Below the list show: 'Times shown in [guestTz]'

Booking form (shown after slot selected):
- guest_name (Input, required)
- guest_email (Input, email, required)
- guest_notes (Textarea, optional, 'Anything I should know?')
- Submit inserts into appointments. On unique constraint error (409/23505), show 'That slot was just booked. Please choose another.'

Confirmation state:
- Show appointment details
- 'Add to calendar' link (.ics download)
- 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.

### 4. 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.

```
Build a host appointments dashboard at src/pages/HostDashboard.tsx (requires auth).

Requirements:
- Fetch all appointments for auth.uid() ordered by starts_at
- Split into Tabs: 'Upcoming' (starts_at > now(), status = confirmed) and 'Past'
- Each appointment as a Card:
  - Guest name (bold) and email (with mailto: link)
  - Start time formatted in host's timezone: 'Tuesday, June 10 at 2:30 PM EDT'
  - Meeting type name
  - Duration as a Badge
  - Guest notes (if present, shown in a lighter text block)
  - Reference ID (first 8 chars of id, monospace)
  - For upcoming: 'Cancel' Button with AlertDialog confirmation

Cancellation:
- UPDATE appointments SET status = 'cancelled' WHERE id = ?
- Optimistic update: remove card from Upcoming list immediately

Header area:
- 'Copy booking link' Button that copies yourdomain.com/schedule/{slug} to clipboard
- 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 example

File: `src/lib/computeSlots.ts`

```typescript
import { parse, addMinutes, format, isAfter, isBefore, parseISO } from 'date-fns'

type AvailabilitySlot = {
  start_time: string // 'HH:mm'
  end_time: string   // 'HH:mm'
}

type ExistingAppointment = {
  starts_at: string  // ISO 8601 UTC
  ends_at: string    // ISO 8601 UTC
}

type TimeSlot = {
  label: string      // '2:30 PM'
  isoUtc: string     // UTC ISO string for storage
}

export function computeAvailableSlots(
  date: Date,
  availability: AvailabilitySlot,
  durationMinutes: number,
  existingAppointments: ExistingAppointment[]
): TimeSlot[] {
  const dateStr = format(date, 'yyyy-MM-dd')

  // Parse availability times as local time on the given date
  const windowStart = parse(
    `${dateStr} ${availability.start_time}`,
    'yyyy-MM-dd HH:mm',
    new Date()
  )
  const windowEnd = parse(
    `${dateStr} ${availability.end_time}`,
    'yyyy-MM-dd HH:mm',
    new Date()
  )

  const slots: TimeSlot[] = []
  let cursor = windowStart
  const now = new Date()

  while (isBefore(cursor, windowEnd)) {
    const slotEnd = addMinutes(cursor, durationMinutes)

    // Skip slots in the past
    if (isAfter(cursor, now)) {
      // Check for conflicts with existing appointments
      const hasConflict = existingAppointments.some((appt) => {
        const apptStart = parseISO(appt.starts_at)
        const apptEnd = parseISO(appt.ends_at)
        return cursor < apptEnd && slotEnd > apptStart
      })

      if (!hasConflict) {
        slots.push({
          label: format(cursor, 'h:mm a'),
          isoUtc: cursor.toISOString(),
        })
      }
    }

    cursor = addMinutes(cursor, durationMinutes)
  }

  return slots
}
```

## Common mistakes

- **Computing slots entirely client-side without any server validation** — The client-side slot list can become stale between the time the guest picks a slot and submits the form. Another guest may have booked the same slot in that window. Fix: 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.
- **Disabling the wrong days on the Calendar component** — The Calendar's disabled prop disables individual dates based on a condition. A common mistake is computing the disabled state before availability data loads, which temporarily enables all dates. Fix: 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)}.
- **Displaying times without clearly labeling the timezone** — A guest in Tokyo and a host in Chicago will see completely different local times. Showing just '2:30 PM' without the timezone context causes confusion and missed appointments. Fix: 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.
- **Allowing guests to book slots that are fewer than a minimum lead time away** — A host who receives a booking for 5 minutes from now has no time to prepare. Same-day or last-minute bookings are often a business problem. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/scheduling-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/scheduling-app
