Vibe code a Calendly clone

Vibe code a Calendly clone with weekly hours, a booking page, and a slot lock. Follow the prompts, check each step, and skip the calendar OAuth.

  • guide
  • vibe-coding
  • calendly

You can vibe code a Calendly clone as weekly hours and a booking page. Calendly is that product. The catalog line is “Your availability rules, one pleasant booking page.”

You pay $12–$20 a person a month. That is $144–$240 a year, and the card says the price scales with the team. The verdict is “Very vibeable”. The first estimate is 6 hours: one meeting type, weekly availability, bookings.

I already wrote the teardown, Vibe code your own Calendly. This guide is the walk. Prompts in order, then what to look at.

The audience is one consultant who offers 30- and 60-minute meetings. If a sales team already shares one Calendly org, keep paying. The same v1 also replaces Cal.com, SavvyCal, and Acuity Scheduling when you wanted a personal booking link.

What you ship when you vibe code a Calendly clone

The Calendly page splits the work into three rows.

The first row is the one you want. One meeting type. Weekday hours. A public page. A booking that lands in a table. That is the 6-hour v1.

The second row is 2 days. Google Calendar sync, timezones, reschedule links. Still one consultant. Do that after a real guest has booked.

The third row is 1 month and up. Team routing, payments, every calendar provider. Skip it.

If you tell an agent to “clone Calendly”, it aims at the third row. Pin every prompt to the first one.

Here is the shape of the data. Nothing else has to exist at 6 hours.

const meetingType = {
  id: 'intro-30',
  title: 'Intro call',
  durationMin: 30,
  timezone: 'America/New_York',
  hours: {
    wdays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
    from: '09:00',
    to: '17:00',
  },
  bufferAfterMin: 15,
  noticeMin: 240,
  horizonDays: 14,
}

const booking = {
  id: 'booking-184',
  meetingTypeId: 'intro-30',
  startAt: '2026-10-26T14:00:00.000Z',
  guestName: 'Jordan Lee',
  guestEmail: '[email protected]',
  guestTimezone: 'Europe/Rome',
  status: 'confirmed',
}

A meeting type, a weekly window, a booking stored in UTC. Date overrides wait until you take a Friday off. The card also lists CalendarConnection. Leave it unused.

MEETING TYPE Intro call, 30 minutes Mon-Fri 09:00-17:00, New York GENERATE SLOTS Hours minus bookings and buffers Show times in the guest timezone HOLD THE SLOT Five-minute hold while they type Expired holds free the slot on read CONFIRM IN ONE WRITE Recheck plus a unique start time
Hours become slots. A hold keeps the form honest. The booking write is the lock.

Pick the stack you already write

Every product prompt ships in four flavors: AHA (Astro, HTMX, Alpine.js), Next.js, Laravel, and Rails. The capability line for Calendly is Astro, HTMX, and D1.

This site is an Astro app on Cloudflare, so that is the path I default to. Use Next.js if that is your weekday language.

Open Build your own Calendly. The guardrail is the useful sentence: prevent double booking with a final availability check inside the booking transaction. The non-goal sits next to it. No teams, round-robin routing, payments, or extra calendar providers in v1.

Pick one stack and stay on it.

Prompt 1. Scaffold Slot and stop

Copy the AHA prompt from the product page. Paste it into your coding tool. Put this block above it.

Ship the 6-hour row only. One meeting type, weekly hours, bookings.

Project name: Slot.
Owner is one person. Guests do not create accounts.

Do not connect Google Calendar.
Do not add team routing, payments, or a second meeting-type marketplace.
Do not add reschedule or cancel links yet.

When the plan is done, stop and wait. Do not write implementation code until I say go.

The official prompt already asks for a short technical plan first: routes, tables, authorization, jobs. Let it produce that plan. Read it.

You want pages like these:

  • /admin/meeting-types for the owner
  • /admin/availability for weekday hours
  • /book/intro-30 for the guest
  • /admin/bookings for the list

You want tables like meeting_types, availability_rules, bookings. A holds table can wait until prompt 4 if the plan already mentions it. calendar_connections should not appear.

If the plan includes OAuth, Stripe, or a users table for guests, send it back. Paste the non-goal again.

When the plan is narrow, say go. Let it scaffold the app, the migrations, and empty screens.

What to check after the scaffold

Start the app. Sign in as the owner. You should see empty admin screens and a public booking URL that 404s until a meeting type exists.

Open the migrations. bookings.start_at is a UTC timestamp. There is no busy_times table from Google. If the README is a placeholder, stop and make the agent write a real one before you add logic.

Prompt 2. Create the meeting type and the hours

Now fill the first two admin screens.

Implement meeting types and weekly availability for Slot.

An owner can create one active meeting type in v1: title, duration
(30 or 60 minutes), location text, buffer after, minimum notice,
and booking horizon.

Weekly hours live on that meeting type. Weekdays plus a from time
and a to time in the owner's IANA timezone, for example
America/New_York. No date overrides yet.

Store every instant in UTC. Keep the timezone name on the meeting
type. Show owner times in that zone with the label next to them.

Seed one meeting type:
- id intro-30
- title Intro call
- 30 minutes
- Mon-Fri 09:00-17:00 America/New_York
- 15 minute buffer after
- 4 hour minimum notice
- 14 day horizon

Done when I can save those fields, refresh, and see the same
numbers. Do not generate slots yet.

Notice that 4 hours of notice is noticeMin: 240. Agents like to invent a 24-hour notice because it looks safer. Pin the numbers. Keep weekday checks out of the slot-slicing file.

What to check after the hours save

Create Intro call. Refresh. Duration 30, hours 09:00-17:00, timezone America/New_York.

Refuse from: 17:00 with to: 09:00. Overnight rules break DST math later. Refuse a notice or horizon of 0.

Prompt 3. Generate the slots the guest sees

The first mini-prompt on the product page is “The slot generator is just interval subtraction”. Use a tighter version so the agent does not pull a calendar feed.

Build slot generation for /book/intro-30.

Inputs: the meeting type's weekly hours, duration, buffer after,
minimum notice, booking horizon, and existing confirmed bookings.
No calendar busy times. No date overrides.

Do the math in UTC. Expand the weekday window into concrete
intervals for the requested day. Subtract each confirmed booking
padded by the buffer. Clip to notice and horizon. Slice what
survives into duration-sized starts.

Display those starts in the guest timezone. Keep a timezone
selector on the page. Default it to the browser timezone.
Every time on screen needs a zone label.

Seed two confirmed bookings on Monday 2026-10-26:
- 14:00 UTC
- 15:00 UTC

Done when /book/intro-30 for that Monday hides 14:00 UTC and
15:00 UTC, keeps 13:30 UTC free if it still fits, and does
not offer a slot inside the 15 minute buffer after 15:00 UTC.

You do not call Calendly’s API. Copy the product rule: weekly hours in a named timezone, bookings as UTC instants. For v1, busy is only your own bookings table. The teardown covers the cooked-vs-raw split if you want it.

A compact version of the subtraction looks like this.

function overlaps(aStart, aEnd, bStart, bEnd) {
  return aStart < bEnd && bStart < aEnd
}

function slotTaken(startAt, durationMin, bufferAfterMin, bookings) {
  const endAt = new Date(startAt.getTime() + durationMin * 60 * 1000)
  const blockedUntil = new Date(endAt.getTime() + bufferAfterMin * 60 * 1000)

  return bookings.some((booking) => {
    const bookedStart = new Date(booking.startAt)
    const bookedEnd = new Date(bookedStart.getTime() + durationMin * 60 * 1000)
    return overlaps(startAt, blockedUntil, bookedStart, bookedEnd)
  })
}

slotTaken(
  new Date('2026-10-26T14:30:00.000Z'),
  30,
  15,
  [{ startAt: '2026-10-26T14:00:00.000Z' }],
)
//true  14:30 sits inside the 15 minute buffer

The page should show a date strip and a list of start times. Empty days say so. Do not render a grid of disabled buttons for every 30-minute mark in the horizon.

What to check after slot generation

Open /book/intro-30 as a guest. Switch the timezone to Europe/Rome. On Monday 26 October 2026, 14:00 UTC is 10:00 in New York and 15:00 in Rome. The label has to say that.

A day past the 14-day horizon has no slots. A start two hours from now is hidden.

Confirm that Monday is missing 14:00 UTC and 15:00 UTC. If 13:30 UTC still fits the window, it stays. 15:30 UTC is gone because of the buffer.

If the list ignores bookings, the generator is reading hours only. Send the seed rows back.

Prompt 4. Book the slot with a lock

The second mini-prompt is “Two guests, one slot, exactly one winner”. Paste this after the generator works.

Build the booking confirmation path.

When a guest picks a time, create a hold row: meeting_type_id,
start_at, expires_at five minutes from now. Show the guest form
for name, email, and timezone.

On submit, in one transaction:
1. Re-check the slot against hours, notice, horizon, bookings,
   and holds that have not expired.
2. Insert the booking.
3. Delete the hold.

Add a unique constraint on (meeting_type_id, start_at) for
confirmed bookings. That is the last line of defense, not a
courtesy check.

The loser sees a specific message: this slot just went, here
are the nearest two alternatives. Expired holds release on
read. No cleanup cron.

Guests do not get accounts. Do not send email yet. Show a
confirmation screen with the UTC instant and both timezone
labels.

Done when two concurrent submits for 13:00 UTC on
2026-10-27 produce one booking and one slot-taken response.

A hold that only lives in memory is useless the moment you run two Workers. Put it in D1.

async function confirmBooking(db, draft) {
  return db.transaction(async (tx) => {
    const open = await tx.slotStillOpen(draft.meetingTypeId, draft.startAt)
    if (!open) {
      return { ok: false, reason: 'slot-taken' }
    }

    try {
      const booking = await tx.insertBooking({
        ...draft,
        status: 'confirmed',
      })
      await tx.deleteHold(draft.holdId)
      return { ok: true, booking }
    } catch (error) {
      if (error.code === 'UNIQUE') {
        return { ok: false, reason: 'slot-taken' }
      }
      throw error
    }
  })
}

Name and email stay server-validated. A missing @ keeps the hold alive and the form filled.

What to check after the lock

Book 13:00 UTC on 2026-10-27 as Jordan. The confirmation shows 13:00 UTC, 09:00 in New York, 14:00 in Rome. Refresh /admin/bookings. The row is there.

Open the same slot in another browser. You get the slot-taken page, not a second row. Reload /book/intro-30. 13:00 UTC is gone.

Wait out a hold without submitting. The slot comes back on the next read after expires_at.

If two bookings share a start time, the unique constraint is missing. Add it before you decorate the confirmation screen.

Failures the agent ships anyway

Google Calendar in step one

The official journeys mention connecting a calendar. That is the 2-day row. If CALENDAR_CLIENT_ID appears in .env.example during the scaffold, delete it and paste the first prompt again.

Floating local times

Agents store 2026-10-26 09:00 with no zone. A guest in Rome then books a slot that never existed in New York. Store UTC. Keep America/New_York on the meeting type and Europe/Rome on the booking.

DST holes

09:30 may not exist on a spring-forward Sunday. Generate from UTC instants, then format. Skip a local wall time that does not exist. Seed hours are weekdays, so you can leave Sunday closed in v1.

A grey button is not a lock

Two guests who loaded the page a minute ago both see 13:00 as free. Only the unique constraint and the transaction decide who gets the slot.

Guest accounts

Guests type a name and an email. Magic links for guests are the 2-day row in disguise.

A vote grid for times, without availability math, is Doodle, $7–$9 a month, about 3 hours. Build Slot if you already know your hours.

Deploy the booking page

The product pages put hosting this way: a focused replacement costs your build time plus close-to-zero hosting.

Slot wants Astro, HTMX, and D1. Put it on a subdomain you already own, book.flaviocopes.com. The AHA prompt points at Cloudflare D1 for the tables and Workers for the endpoints.

Ship with the seed meeting type. Book yourself from a phone. Confirm the admin list shows that row after a refresh. Point one real intro-call link at it for a week.

If you miss reschedule links or a .ics attachment, those are the 2-day row and the last two mini-prompts on the card. Add them after a guest asks.

Take a D1 backup, then cancel Calendly, or Cal.com, or SavvyCal, or Acuity, whichever one you pay for.

What to skip on purpose

Leave out team routing, payments, and a second calendar provider.

6-HOUR V1 One meeting type and weekly hours Slots from hours minus bookings Hold, then one locking write SKIP THIS HALF Team routing and round robin Payments on the booking Every calendar provider
Ship the 6-hour page. Leave teams, payments, and extra calendars on the skip row.

Google Calendar sync is the honest next feature, not a v1 requirement. The teardown explains why busy times and DST make that row longer than it looks.

A one-question intake form in front of the call is Typeform, $29–$99 a month, about 2 hours. The Typeform walkthrough is that post.

More writeups land on the guides index. The audit is the catalog if you want a smaller first ship.

Start now

Go to Build your own Calendly. Copy the AHA prompt, or the stack you already write.

Tell the agent the first estimate only: one meeting type, weekly hours, bookings. Six hours.

Publish book.flaviocopes.com. Send one real intro-call link this week.