TUTORIAL // SAAS REPLACEMENT BLUEPRINT

How to Build a Self-Hosted Calendly & Cal.com Replacement with an AI Agent

With the rising subscription prices of SaaS and the ease of deploying our own versions, let's take a stab at crafting a careful plan, having an agent execute it for us, and getting ready to deploy it for ourselves. In this blueprint, you will build a production-grade meeting scheduling engine complete with multi-timezone slot calculations, preparation buffer management, universal RFC 5545 .ics calendar invites, and database-level double-booking protection using PostgreSQL GIST exclusion constraints.

Author: Senior Engineering Team @ PromptScale • Read time: 12 min • Stack: Next.js 14 • PostgreSQL • TypeScript
PART 01 // ARCHITECTURAL FOUNDATION

Part 1: A Great Application Starts from a Great Plan

When you open an AI coding agent like Claude Code, Cursor, or Windsurf and say, "Build me a Calendly clone in Next.js," you will get code in twenty seconds. It will have pretty Tailwind buttons, a calendar grid, and time-slot pills. It looks like a miracle.

Then you test it in the real world, and the illusions vanish:

  • Race-Condition Double Bookings: Two guests click "Confirm" at the same millisecond. Because the agent wrote a naive SELECT COUNT(*) check before INSERT, both checks see zero bookings, both insert, and your host is double-booked.
  • Daylight Saving Time Chaos: Recurring weekly schedules get stored as UTC timestamps instead of host wall-clock time. When the clocks spring forward or fall back, the host's 9:00 AM working hours suddenly shift to 8:00 AM or 10:00 AM.
  • Buffer Bleed: The agent computes buffers on the fly with JavaScript Date arithmetic, corrupting back-to-back preparation windows when meetings wrap up.
  • Vendor Lock-in & Broken Invites: The agent immediately attempts to integrate Google Calendar OAuth or third-party APIs that require production verification keys, making the application impossible to test locally in an offline environment.

The solution is not prompting faster. The solution is forcing your agent to adopt Strict Planning Mode. Before your agent creates or modifies a single file, it must be handed an exhaustive, production-vetted architectural blueprint with database constraints, modular file caps, and automated regression test gates.

Here's how you can use this tutorial

There are three flexible ways to work through this guide depending on your workflow:

  • Download the Markdown Plan: You can download the complete calendly-booking-engine.plan.md file directly into your repository directory and point your agent at it with a single terminal command.
  • Copy and Paste: You can jump directly to Part 2 and copy the full plan, or view the raw verbatim prompt page, and paste it straight into your agent's chat or Cursor Composer window.
  • Follow Along for Ideas: You can read through each section to understand how to craft your own comprehensive, bulletproof plans for bespoke internal tools.

The core philosophy here is simple: before your agent starts to write code, it has a plan that is set up for success, the PromptScale way.

PART 02 // THE SPECIFICATION

Part 2: Here Is the Plan We Can Give to Our Agent — Start to Finish

Below is the exact, production-vetted specification (calendly-booking-engine.plan.md, Version 1.9.0). It lays out the architectural constraints, PostgreSQL DDL schema, pure-function slot math algorithm, under-200-line modular file tree, and 5 sequential execution phases.

calendly-booking-engine.plan.md (326 lines)
# Blueprint: Availability & Booking Engine (Cal.com / Calendly Clone)
**Version:** 1.9.0 (Zero-Deadlock Autonomous Phased Verification Edition)
**Target Agents:** Claude Code, Cursor, Windsurf, Devin, Grok
**Methodology:** Strict Planning Mode, Multi-Timezone UTC Math, Per-Host Lock + GIST Exclusion Constraints, Pure Slot Engine, Autonomous Phased Test Gates

---

## Execution Protocol for the Agent (Autonomous Phased Verification)
You are an autonomous principal engineer. You must execute this plan sequentially from Phase 1 through Phase 5 without requiring manual human prompts between phases:
1. **Phased State Machine:** Implement strictly one phase at a time under Section 5. Never scaffold, modify, or jump ahead to future phases out of order.
2. **Automated Test Gate Verification:** At the end of each phase, execute that phase's designated verification test command (e.g., `pnpm run test:gate1` or `pnpm test`).
3. **Self-Healing Loop:** If a verification test fails, inspect the stack trace and error message, apply fixes to the implementation code, and re-run until passing (up to 3 remediation cycles). Never weaken or delete assertions to force a green test.
4. **Autonomous Progression:** When Phase [N] tests pass with 0 errors, log a concise `[PHASE N COMPLETE - GATE PASSED]` summary to stdout and immediately transition to Phase [N+1].
5. **Final Delivery:** Stop only after Phase 5 integration tests and `pnpm run build` pass cleanly. Deliver the final test report and instructions to launch the local application with `pnpm run dev`.
*(Note: If the user explicitly commands a checkpoint mode like "pause for review", pause and await user approval before advancing; otherwise, proceed autonomously.)*

---

## 1. Executive Summary & Tech Stack Constraints
A multi-timezone meeting scheduling platform that lets hosts publish weekly availability schedules, handles custom buffer times between appointments, prevents double-booking via per-host row locking and a database-level GIST exclusion constraint, and generates RFC 5545 `.ics` calendar files.

### Architectural Constraints:
- **Runtime & Framework:** Node.js 20.11+ (or 22 LTS) with Next.js 14.2+ (App Router). In Next.js 15+, dynamic server helpers like `headers()` return Promises, so all header reads must use `await headers()`.
- **Language:** TypeScript 5+ (Strict Mode).
- **Database:** PostgreSQL 14+ (Docker / Local / Neon / Supabase) with UTC `TIMESTAMPTZ` enforcement; requires the `btree_gist` extension.
- **Database Driver:** `postgres` (`postgres-js`) configured with `{ prepare: false }`. This is mandatory for compatibility with transaction poolers (Supabase PgBouncer on port 6543) and Neon while supporting interactive transactions for `SELECT ... FOR UPDATE`.
- **ORM & Migrations (No `drizzle-kit push`):** Drizzle ORM. Because Drizzle's TypeScript schema DSL cannot natively express PostgreSQL `EXCLUDE USING gist` constraints, running `drizzle-kit push` (`db:push`) will silently skip or drop the double-booking constraint. **Never define or run `db:push`.** The single source of truth for DDL is `src/db/migrations/0001_init.sql` applied idempotently via `src/db/migrate.ts` (`pnpm run db:migrate`). `src/db/schema.ts` provides the TypeScript query layer mirroring the tables.
- **Isolated Test Database:** Tests must **never** run against or wipe the development database (`DATABASE_URL`). `tests/setup/pg-container.ts` must connect to `DATABASE_URL_TEST` if set, or derive a dedicated test database (e.g. `calendly_test` created automatically on the local Postgres server, or a Testcontainers PostgreSQL container).
- **Pinned Test Tooling:** Pin `vitest@^3.0.0` and `@testcontainers/postgresql@^10.0.0` so the test runner works seamlessly on both Node 20 and Node 22.
- **Timezone Library & Hydration Safety:** `date-fns@^3` and `date-fns-tz@^3` (using `fromZonedTime` and `formatInTimeZone`). Validate host and guest timezones using `try { new Intl.DateTimeFormat(undefined, { timeZone }); } catch { ... }` in Zod schemas. To prevent React SSR/client hydration mismatches caused by differing ICU timezone lists between Node.js and browsers, compute `const timezones = Intl.supportedValuesOf('timeZone')` on the server in `app/[username]/[slug]/page.tsx` and pass it as a prop to `<TimezoneSelector timezones={timezones} />`. Detect the guest's default browser timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) inside a `useEffect` hook after mount.
- **Double-Booking & Deadlock Prevention (`23P01` + `40P01`):**
  1. Database enforces `EXCLUDE USING gist (host_id WITH =, tstzrange(blocked_start, blocked_end, '[)') WITH &&) WHERE (status = 'confirmed')`.
  2. Inside `bookSlot`, serialize concurrent booking attempts for the same host by acquiring a per-host row lock (`SELECT id FROM hosts WHERE id = $1 FOR UPDATE`) at the start of the transaction before checking availability and inserting. This prevents concurrent GIST index page lock upgrades from deadlocking and ensures a 5-way race finishes in under 200ms.
  3. Catch PostgreSQL `SQLSTATE 23P01` (`exclusion_violation`, via `err.code === '23P01' || err.cause?.code === '23P01'`) and map it to `{ success: false, error: 'SLOT_TAKEN' }`.
  4. If PostgreSQL raises `SQLSTATE 40P01` (`deadlock_detected`), retry the transaction up to 4 times with jittered backoff (15–50ms); if retries are exhausted or a conflict is found, return `{ success: false, error: 'SLOT_TAKEN' }`.
- **Zero-Account Rate Limiting & Safe Client IP:**
  - `src/lib/rate-limiter.ts` implements a built-in, zero-account in-memory sliding-window rate limiter (max 5 booking attempts per IP per 60 seconds) by default, and optionally upgrades to `@upstash/ratelimit` only when `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are configured.
  - `src/lib/client-ip.ts` reads `x-real-ip` first, only trusts `x-forwarded-for` when `process.env.TRUST_PROXY === '1'` (taking the rightmost hop), and falls back to `'127.0.0.1'` so spoofed `X-Forwarded-For` headers cannot bypass rate limits by default.
- **Host Dashboard Protection:** `app/dashboard/availability/page.tsx` and its update action require a host passcode/token matching `process.env.ADMIN_TOKEN` (defaulting to `'demo-admin-token'` in `.env.example`) via an `httpOnly` cookie or header so unauthenticated guests cannot overwrite the host's schedule.
- **Booking Core & Entry Points:** `server/booking/book-slot.ts` exports `bookSlot(input, deps)` and holds all booking logic. It is a plain server module with **no `"use server"` directive**, so `deps` (clock, rate limiter, IP, db) can never be supplied by a browser. Three entry points wire it up:
  - `server/actions/book-slot-action.ts` (`"use server"`): accepts only `input`, reads IP via `clientIp(await headers())`, calls `bookSlot(input, { ip })`.
  - `app/api/bookings/route.ts`: `POST` endpoint that parses JSON, reads IP, calls `bookSlot`, and returns HTTP 201 (or 409 on `SLOT_TAKEN`, 429 on `RATE_LIMITED`, 400 on validation error).
  - `app/api/bookings/[id]/ics/route.ts`: `GET` endpoint that serves the booking's RFC 5545 `.ics` file with `Content-Type: text/calendar; charset=utf-8` and `Content-Disposition: attachment; filename="invite.ics"`.
- **File Limit:** Under 200 lines per modular file.

---

## 2. Relational Schema (PostgreSQL DDL)

> Apply idempotently via raw SQL in `src/db/migrations/0001_init.sql` through `src/db/migrate.ts`.

```sql
-- Enable GIST extension for UUID and scalar equality support
CREATE EXTENSION IF NOT EXISTS btree_gist;

-- Hosts / Users
CREATE TABLE IF NOT EXISTS hosts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(100) UNIQUE NOT NULL,
    full_name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    timezone VARCHAR(100) NOT NULL DEFAULT 'UTC', -- e.g. 'America/New_York'
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Event Types (e.g. 30-min intro call)
CREATE TABLE IF NOT EXISTS event_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    slug VARCHAR(100) NOT NULL,
    duration_minutes INT NOT NULL DEFAULT 30 CHECK (duration_minutes > 0),
    before_buffer_minutes INT NOT NULL DEFAULT 5 CHECK (before_buffer_minutes >= 0),
    after_buffer_minutes INT NOT NULL DEFAULT 10 CHECK (after_buffer_minutes >= 0),
    min_notice_minutes INT NOT NULL DEFAULT 120 CHECK (min_notice_minutes >= 0),
    max_horizon_days INT NOT NULL DEFAULT 60 CHECK (max_horizon_days > 0),
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_host_slug UNIQUE (host_id, slug),
    CONSTRAINT uq_event_types_host UNIQUE (id, host_id)
);

-- Weekly Availability Windows (stored as local TIME in host's timezone)
CREATE TABLE IF NOT EXISTS availability_schedules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
    day_of_week INT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sunday
    start_time TIME NOT NULL, -- e.g. 09:00:00
    end_time TIME NOT NULL,   -- e.g. 17:00:00 (Overnight windows split across days)
    CHECK (end_time > start_time)
);

-- Specific-date overrides (days off, one-off hours, holidays)
CREATE TABLE IF NOT EXISTS date_overrides (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    host_id UUID NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
    override_date DATE NOT NULL,
    is_unavailable BOOLEAN NOT NULL DEFAULT FALSE,
    start_time TIME,
    end_time TIME,
    CHECK (
        is_unavailable = TRUE
        OR (start_time IS NOT NULL AND end_time IS NOT NULL AND end_time > start_time)
    ),
    CONSTRAINT uq_host_date_override UNIQUE (host_id, override_date)
);

-- Booked Appointments
CREATE TABLE IF NOT EXISTS bookings (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_type_id UUID NOT NULL,
    host_id UUID NOT NULL,

    guest_name VARCHAR(255) NOT NULL,
    guest_email VARCHAR(255) NOT NULL,
    guest_timezone VARCHAR(100) NOT NULL,
    guest_notes TEXT,

    start_time TIMESTAMPTZ NOT NULL, -- UTC; actual meeting start
    end_time TIMESTAMPTZ NOT NULL,   -- UTC; actual meeting end

    -- Immutable buffer snapshots captured on insert
    blocked_start TIMESTAMPTZ NOT NULL, -- start_time - before_buffer_minutes
    blocked_end   TIMESTAMPTZ NOT NULL, -- end_time   + after_buffer_minutes

    status VARCHAR(20) NOT NULL DEFAULT 'confirmed'
        CHECK (status IN ('confirmed', 'cancelled', 'rescheduled')),

    cancel_token UUID NOT NULL DEFAULT gen_random_uuid(),
    rescheduled_from_id UUID REFERENCES bookings(id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

    CHECK (end_time > start_time),
    CHECK (blocked_start <= start_time),
    CHECK (blocked_end >= end_time),

    CONSTRAINT fk_bookings_event_type_host
        FOREIGN KEY (event_type_id, host_id)
        REFERENCES event_types(id, host_id)
        ON DELETE RESTRICT,

    CONSTRAINT uq_bookings_cancel_token UNIQUE (cancel_token)
);

-- Exclusion constraint: prevents overlapping blocked ranges for confirmed bookings of the same host (idempotent block).
DO $$
BEGIN
    IF NOT EXISTS (
        SELECT 1 FROM pg_constraint WHERE conname = 'no_overlap_per_host'
    ) THEN
        ALTER TABLE bookings
            ADD CONSTRAINT no_overlap_per_host
            EXCLUDE USING gist (
                host_id WITH =,
                tstzrange(blocked_start, blocked_end, '[)') WITH &&
            ) WHERE (status = 'confirmed');
    END IF;
END $$;
```

---

## 3. Modular File System Architecture (Hard 200-Line Cap)

```
├── drizzle.config.ts                      # Drizzle configuration
├── vitest.config.ts                       # Vitest test runner configuration
├── .env.example                           # DATABASE_URL, DATABASE_URL_TEST, NEXT_PUBLIC_APP_URL, ADMIN_TOKEN
src/
├── app/
│   ├── [username]/[slug]/page.tsx          # Public booking calendar Server Component (< 140 lines)
│   ├── dashboard/availability/page.tsx    # Host weekly schedule editor (< 160 lines)
│   ├── api/bookings/route.ts              # POST booking HTTP endpoint (< 60 lines)
│   └── api/bookings/[id]/ics/route.ts     # GET RFC 5545 .ics download endpoint (< 60 lines)
├── components/
│   ├── calendar/
│   │   ├── booking-shell.tsx              # Client state wrapper coordinating date, slot, form & confirmation (< 140 lines)
│   │   ├── day-grid.tsx                   # Day picker (< 110 lines)
│   │   ├── time-slot-column.tsx           # Time slot pills (< 130 lines)
│   │   └── guest-details-form.tsx         # Guest input form (< 120 lines)
│   ├── booking/
│   │   ├── booking-confirmed-card.tsx     # Confirmation screen + .ics download link (< 90 lines)
│   │   └── timezone-selector.tsx          # Server-seeded IANA timezone dropdown (< 85 lines)
│   └── ui/
│       └── button.tsx, dialog.tsx         # Atomic UI primitives
├── server/
│   ├── actions/
│   │   ├── book-slot-action.ts            # "use server" booking wrapper (< 40 lines)
│   │   └── availability-action.ts         # "use server" host schedule updater with ADMIN_TOKEN check (< 80 lines)
│   ├── booking/
│   │   └── book-slot.ts                   # Core booking transaction with host lock & 23P01/40P01 handling (< 190 lines)
│   └── slot-engine/
│       ├── compute-slots.ts               # Pure calculation algorithm (< 180 lines)
│       ├── get-available-slots.ts         # Padded database loader (< 110 lines)
│       └── timezone-converter.ts          # Timezone utilities (< 110 lines)
├── db/
│   ├── client.ts                          # postgres-js connection with prepare: false (< 50 lines)
│   ├── migrate.ts                         # Raw SQL migration runner (< 60 lines)
│   ├── seed.ts                            # Idempotent 'demo-host' & '30-min-intro' seeder (< 80 lines)
│   ├── schema.ts                          # Drizzle table schemas (< 180 lines)
│   └── migrations/
│       └── 0001_init.sql                  # Raw SQL DDL with btree_gist and GIST constraint
└── lib/
    ├── client-ip.ts                       # Safe IP header extraction (< 40 lines)
    ├── ics-generator.ts                   # RFC 5545 generator with SEQUENCE tracking (< 120 lines)
    ├── rate-limiter.ts                    # Built-in sliding-window limiter + optional Upstash (< 70 lines)
    └── validation.ts                      # Zod validation schemas (< 80 lines)

tests/
├── setup/
│   └── pg-container.ts                    # Isolated test DB harness: DATABASE_URL_TEST, dedicated test DB, or Testcontainers (< 85 lines)
├── slot-engine.test.ts                    # Gate 1: Pure unit tests for DST, buffers, timezones
├── ics-generator.test.ts                  # Gate 1: RFC 5545 compliance & line folding tests
├── slot-loader.test.ts                    # Gate 2: Boundary padding integration tests
└── booking-concurrency.test.ts            # Gate 2: 5-way concurrent race & single-use reschedule token tests
```

---

## 4. State Machine & Business Logic Invariants

1. **Per-Host Serialization + GIST Exclusion Constraint:**
   - Every confirmed booking occupies `tstzrange(blocked_start, blocked_end, '[)')`.
   - At the start of `bookSlot`'s transaction, execute `SELECT id FROM hosts WHERE id = $1 FOR UPDATE` to serialize concurrent writes for the same host and prevent GIST index page deadlocks (`40P01`).
   - The database also enforces `no_overlap_per_host` via GIST exclusion (`SQLSTATE 23P01`). `bookSlot` catches `23P01` and retries up to 4 times on `40P01` before returning `{ success: false, error: 'SLOT_TAKEN' }`.
2. **Buffer Stacking Invariant:** Blocked spans include `before_buffer_minutes` and `after_buffer_minutes`. Because the database prevents blocked spans from overlapping, the minimum gap between two consecutive meetings is the first meeting's after-buffer plus the second meeting's before-buffer.
3. **Pure Slot Calculation (Zero I/O):**
   - Candidate slots are stepped on a fixed 30-minute (or `duration_minutes`) grid in host wall-clock time.
   - **DST Round-Trip Check:** For each candidate, convert `(hostDate, localTime)` to UTC via `fromZonedTime(..., hostTimezone)`. Then format back using `formatInTimeZone(candStartUtc, hostTimezone, 'HH:mm')`. If the formatted time does not match `localTime`, the time fell into a non-existent spring-forward hour and is skipped.
   - **UTC Duration Invariance:** `candEndUtc = new Date(candStartUtc.getTime() + duration_minutes * 60_000)`. Never compute local end time and convert back across DST shifts.
   - Returns a flat array of UTC slots `{ startUtc: string; endUtc: string }[]`.
4. **Deterministic Testing:** `computeSlots` and `bookSlot` accept `now` as an explicit parameter so tests are 100% deterministic regardless of current date or system clock.
5. **Reschedule State Machine & UID Preservation:**
   - Rescheduling locks the old booking row via `SELECT ... FOR UPDATE` on `cancel_token` (after locking the host row), verifies `status = 'confirmed'`, marks the old booking `'rescheduled'`, and inserts the new booking with `rescheduled_from_id = oldBooking.id` inside the same transaction.
   - In `.ics` generation, if `rescheduled_from_id` is present, preserve the **original booking's UID** (`booking-${rescheduledFromId}@promptscale.dev`) and increment `SEQUENCE` (e.g. `SEQUENCE:1`).

---

## 5. Phase-by-Phase Implementation Prompts for Agents

### Phase 1: Database Setup, Raw SQL Migrations & Isolated Test Harness
```
Execute Phase 1:
1. Initialize the Next.js 14+ TypeScript project in the current directory (requiring Node >= 20.11):
   pnpm create next-app@14 . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
   pnpm add drizzle-orm postgres date-fns@^3.6.0 date-fns-tz@^3.2.0 zod @upstash/ratelimit ical.js
   pnpm add -D drizzle-kit vitest@^3.0.0 @testcontainers/postgresql@^10.0.0 tsx @types/node
2. Define these exact scripts in package.json (never use drizzle-kit push because it skips GIST exclusion constraints):
   - "db:migrate": "tsx src/db/migrate.ts"
   - "db:seed": "tsx src/db/seed.ts"
   - "test:gate1": "vitest run tests/slot-engine.test.ts tests/ics-generator.test.ts"
   - "test:gate2": "vitest run tests/slot-loader.test.ts tests/booking-concurrency.test.ts"
   - "test:concurrency": "vitest run tests/booking-concurrency.test.ts"
   - "test": "pnpm run test:gate1 && pnpm run test:gate2"
3. Create .env.example with DATABASE_URL, DATABASE_URL_TEST, NEXT_PUBLIC_APP_URL="http://localhost:3000", and ADMIN_TOKEN="demo-admin-token".
4. Create src/db/client.ts using postgres-js with { prepare: false }.
5. Create src/db/migrations/0001_init.sql with the complete idempotent DDL from Section 2, including CREATE EXTENSION IF NOT EXISTS btree_gist and the no_overlap_per_host GIST exclusion constraint.
6. Create src/db/migrate.ts to apply 0001_init.sql against a target connection string.
7. Create src/db/schema.ts mirroring the tables in Drizzle syntax for type-safe queries.
8. Create src/db/seed.ts: idempotently upsert 'demo-host' in 'America/New_York' with a 30-min event ('30-min-intro', 5m before-buffer, 10m after-buffer, 120m min_notice, 60d max_horizon) and Mon-Fri 09:00-12:00, 13:00-17:00 availability.
9. Create tests/setup/pg-container.ts: NEVER run tests against the dev DATABASE_URL directly. Connect to DATABASE_URL_TEST if provided, or create/connect to a dedicated `calendly_test` database (or isolated `test_run` schema) on the Postgres host, or start a Testcontainers PostgreSQL container. Run migrate and seed against the isolated test DB, and verify that inserting an overlapping confirmed booking raises SQLSTATE 23P01.
Keep all files under 200 lines. Run verification, confirm passing, log [PHASE 1 COMPLETE - GATE PASSED], and immediately advance to Phase 2.
```

### Phase 2: Pure Slot Generator, Padded Loader & Gate 1 Slot Tests
```
Execute Phase 2:
1. Build src/server/slot-engine/timezone-converter.ts with helpers for host wall-clock to UTC conversion and IANA timezone validation.
2. Build src/server/slot-engine/compute-slots.ts as a pure function (zero I/O, accepts explicit `now`):
   - Lay out a fixed grid in host local time across the requested date window, honouring availability_schedules and date_overrides.
   - Perform the DST round-trip check via formatInTimeZone to skip spring-forward gaps.
   - Compute candEndUtc strictly in UTC duration (candStartUtc + duration_minutes).
   - Enforce min_notice_minutes and max_horizon_days relative to `now`.
   - Exclude candidates whose [candStartUtc - beforeBuffer, candEndUtc + afterBuffer) overlaps any confirmed booking's [blocked_start, blocked_end).
   - Return a flat list of valid UTC slot objects: { startUtc: string; endUtc: string }[].
3. Build src/server/slot-engine/get-available-slots.ts to query Drizzle with boundary buffer padding (querying bookings padded by max buffer + 1 day on both ends of the date window).
4. Build tests/slot-engine.test.ts (Gate 1 unit tests, zero DB required):
   - Standard weekday slots across Pacific, Eastern, and Tokyo timezones.
   - Fixed-grid buffer collision: 10:00-10:30 booking with 10m after-buffer and 5m before-buffer excludes 10:30; next offered slot is 11:00.
   - US DST spring-forward (March 9, 2025): 02:00 slot skipped; offers [01:00, 01:30, 03:00, 03:30].
   - US DST fall-back (Nov 2, 2025): 01:30 candidate resolves deterministically with exact 30m physical UTC duration.
   - Half-hour Asia/Kolkata timezone mapping and date override (holiday / custom hours) handling.
Run `pnpm vitest run tests/slot-engine.test.ts`. Confirm passing, log [PHASE 2 COMPLETE - GATE PASSED], and immediately advance to Phase 3.
```

### Phase 3: Atomic Booking Core, RFC 5545 `.ics` Generator, API Routes & Gate 1 `.ics` Tests
```
Execute Phase 3:
1. Build src/lib/validation.ts (Zod schemas with Intl.DateTimeFormat timezone check), src/lib/client-ip.ts (reads x-real-ip, only trusts x-forwarded-for when TRUST_PROXY=1, defaults to '127.0.0.1'), and src/lib/rate-limiter.ts (built-in in-memory sliding-window limiter allowing 5 req/min per IP by default, with optional Upstash Redis when env vars are present).
2. Build src/lib/ics-generator.ts:
   - Generate RFC 5545 .ics content with UID, UTC DTSTAMP/DTSTART/DTEND, CRLF (\r\n) line endings throughout, text escaping, and 75-octet UTF-8 byte line folding.
   - If rescheduled_from_id is set, preserve original booking UID and increment SEQUENCE (e.g. SEQUENCE:1).
3. Build src/server/booking/book-slot.ts (plain server module, no "use server"):
   - Validate input with Zod and check rate limiter against client IP.
   - Open an interactive database transaction. First execute `SELECT id FROM hosts WHERE id = $1 FOR UPDATE` to serialize concurrent booking writes for the same host and prevent 40P01 GIST deadlocks.
   - If rescheduling via cancelToken, lock old booking row with `SELECT ... FOR UPDATE`, verify status === 'confirmed', and mark old row 'rescheduled'.
   - Re-verify that the requested slot exists in computeSlots (excluding the old booking when rescheduling).
   - Compute blocked_start and blocked_end immutably from event type buffers and insert the confirmed booking.
   - Catch SQLSTATE 23P01 (exclusion_violation) and return { success: false, error: 'SLOT_TAKEN' }. If SQLSTATE 40P01 (deadlock_detected) occurs, retry up to 4 times with short backoff, then return { success: false, error: 'SLOT_TAKEN' }.
4. Build src/server/actions/book-slot-action.ts ("use server"), src/app/api/bookings/route.ts (POST handler), and src/app/api/bookings/[id]/ics/route.ts (GET handler returning text/calendar attachment).
5. Build tests/ics-generator.test.ts: verify RFC 5545 escaping, CRLF endings, UID preservation + SEQUENCE increment on reschedule, and 75-octet line folding parsed via ical.js.
Run `pnpm run test:gate1`. Confirm both slot-engine and ics-generator test suites pass, log [PHASE 3 COMPLETE - GATE PASSED], and immediately advance to Phase 4.
```

### Phase 4: Public Booking UI, Confirmation Screen & Host Availability Dashboard
```
Execute Phase 4:
1. Build components/booking/timezone-selector.tsx: accepts `timezones: string[]` prop generated on the server (`Intl.supportedValuesOf('timeZone')`) so server and browser option lists never mismatch during hydration. Detect `Intl.DateTimeFormat().resolvedOptions().timeZone` inside a `useEffect` hook after mount.
2. Build components/calendar/day-grid.tsx (groups flat UTC slots into guest local calendar dates using formatInTimeZone), components/calendar/time-slot-column.tsx (renders slot pills in guest timezone), and components/calendar/guest-details-form.tsx (name, email, notes, and submit handler wired to bookSlotAction).
3. Build components/booking/booking-confirmed-card.tsx: displays confirmed meeting details in guest timezone, cancel/reschedule token info, and a download button linking to `/api/bookings/[id]/ics`.
4. Build components/calendar/booking-shell.tsx and wire `src/app/[username]/[slug]/page.tsx` to load host, event type, and available slots, passing server-generated `timezones` to the client shell.
5. Build `src/app/dashboard/availability/page.tsx` and `src/server/actions/availability-action.ts`: allow the host to view and update weekly `availability_schedules` and `date_overrides` protected by `ADMIN_TOKEN`.
Keep all files under 200 lines. Run `pnpm run build` and `pnpm run test:gate1` to verify clean TypeScript compilation and SSR build. Log [PHASE 4 COMPLETE - GATE PASSED] and immediately advance to Phase 5.
```

### Phase 5: Gate 2 Concurrency & Boundary Integration Tests
```
Execute Phase 5:
1. Build tests/slot-loader.test.ts: verify boundary buffer padding queries against the isolated test database harness (tests/setup/pg-container.ts), confirming bookings just outside a UTC day boundary still block overlapping slots across the boundary.
2. Build tests/booking-concurrency.test.ts:
   - Test 1 (Exact Slot 5-Way Race): Call bookSlot 5 times concurrently (Promise.all) for the exact same slot. Assert the race completes in under 5 seconds, exactly 1 booking succeeds with status 'confirmed', and the remaining 4 fail cleanly with { error: 'SLOT_TAKEN' } or { error: 'INVALID_SLOT' } (0 unhandled 40P01 or 23P01 exceptions).
   - Test 2 (Overlapping Buffer Race): Call bookSlot concurrently for 10:00 and 10:30 when the 10:00 meeting's after-buffer overlaps the 10:30 meeting's before-buffer. Assert exactly 1 succeeds and 1 fails with SLOT_TAKEN or INVALID_SLOT.
   - Test 3 (Single-Use Reschedule Token Race): Fire 2 concurrent reschedule calls using the same cancel_token. Assert 1 succeeds, 1 returns INVALID_TOKEN, and the rescheduled booking's .ics preserves the original UID with SEQUENCE:1.
3. Run the complete verification suite: `pnpm test && pnpm run build`.
Once passing with 0 errors, log [PHASE 5 COMPLETE - ALL GATES PASSED] and output instructions to run `pnpm run db:migrate && pnpm run db:seed && pnpm run dev` and open http://localhost:3000/demo-host/30-min-intro.
```
PART 03 // ARCHITECTURAL REASONING

Part 3: Here Are the Technologies That Will Be Used & Why

Every tool in this stack was chosen for an explicit technical reason to solve a specific hard problem. Here is why each technology is essential:

1. Next.js 14+ (App Router) & TypeScript 5+ (Strict Mode)

Provides a unified server/client boundary. Public booking pages are rendered with fast React Server Components, while form submissions and slot queries run through type-safe Server Actions. Strict Mode ensures that timezone conversions and dates are never inadvertently cast to ambiguous any types.

2. PostgreSQL 14+ with the btree_gist Extension

Standard relational databases do not know how to enforce overlapping time ranges. Application-level locks (like advisory locks) fail across hour boundaries or block waiting threads.

By enabling btree_gist, PostgreSQL allows us to combine scalar equality (host_id WITH =) with timestamp range overlap (tstzrange(blocked_start, blocked_end) WITH &&). Overlapping bookings are rejected atomically by the database storage engine with SQLSTATE 23P01.

3. Drizzle ORM

Unlike heavy ORMs that hide raw SQL capabilities, Drizzle allows us to write native SQL for specialized PostgreSQL features like GIST exclusion constraints while giving our application queries full TypeScript type inference and zero query-overhead.

4. date-fns-tz

Timezone calculation in JavaScript is notoriously buggy. date-fns-tz is modern, functional, and tree-shakeable. It provides reliable IANA timezone conversions (fromZonedTime and toZonedTime) without bundling hundreds of kilobytes of legacy formatting tables.

5. Vitest & Testcontainers

We divide our test suite into two gates. Gate 1 runs ultra-fast in-memory unit tests on pure timezone math without needing a database. Gate 2 spins up an ephemeral PostgreSQL instance via Testcontainers to verify real database transactions and concurrent race conditions.

PART 04 // AGENT EXECUTION DISCIPLINE

Part 4: Send Your Plan Through Your Agent

Having a great plan is only half the battle. How you instruct your agent to execute that plan determines whether you get clean, modular code or a tangled mess.

Modern coding agents don't need you to babysit them with manual copy-and-paste prompts at every step. Because our blueprint incorporates an Autonomous Phased Verification Protocol, your agent can execute the entire project from start to finish in a single session—verifying each automated test gate and self-healing before advancing.

The Single Kickoff Prompt

To launch the build, simply point your agent at the plan with this single master command:

In Cursor Composer (Cmd+I or Ctrl+I):
"Read @calendly-booking-engine.plan.md. Follow the Autonomous Phased Verification Protocol: execute Phases 1 through 5 sequentially. Run and verify each phase's automated test gate before advancing. If a test fails, apply fixes until it passes. Deliver the final test report when Phase 5 completes."
In Claude Code CLI:
claude -p "Read calendly-booking-engine.plan.md. Follow the Autonomous Phased Verification Protocol: execute Phases 1 through 5 sequentially. Run and verify each phase's automated test gate before advancing. If a test fails, apply fixes until it passes. Deliver the final test report when Phase 5 completes."

How the Autonomous Verification Loop Works

Rather than guessing whether code works or dumping hundreds of unverified lines into your workspace, the agent navigates five self-verifying test gates:

GATE 1

Database DDL & Exclusion Harness (Phase 1)

Applies raw SQL schema with btree_gist, seeds demo host, and asserts exclusion constraint rejects overlapping appointments.

GATE 2

Pure Timezone & DST Slot Math (Phase 2)

Executes in-memory unit tests in Vitest across US Eastern spring-forward/fall-back shifts, buffers, and half-hour offsets with zero network I/O.

GATE 3

Atomic Booking Core, Per-Host Lock & RFC 5545 Invites (Phase 3)

Serializes host writes via FOR UPDATE, catches 23P01/40P01, generates RFC 5545 .ics invites, and validates 75-octet folding with ical.js.

GATE 4

Public Calendar UI, Confirmation & Host Dashboard (Phase 4)

Renders hydration-safe timezone selector, client date grouping, confirmation card with .ics download, and host availability editor.

GATE 5

Race-Condition Concurrency Suite (Phase 5)

Fires 5 simultaneous bookings via Promise.all against PostgreSQL to confirm exactly 1 succeeds and 4 receive structured SLOT_TAKEN errors.

Pro Tip
Prefer manual checkpoints? If you want human sign-off between phases, simply add "pause for review after each phase" to your prompt. The agent will stop and ask for your approval at each gate before advancing.
PART 05 // LOCAL WORKFLOW

Part 5: Here Are the Commands That You Can Run to Test, Build, and Run Locally

Before running your booking engine, your application needs a PostgreSQL database connection where it can store host schedules and booked appointments.

Setting Up Your Database (Zero-Install Path)

You don't need to install complicated database software on your computer. The simplest way—especially if you're not a database administrator—is to sign up for a free PostgreSQL database on a managed service like Neon or Supabase in 60 seconds.

Once created, copy the connection string, create a file named .env in your project root, and paste it:

DATABASE_URL="postgres://your_user:[email protected]/neondb?sslmode=require"

Power-User Alternative: If you are a developer who already has Docker installed, you can optionally run local Postgres with: docker run --name calendly-pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres:16.

The 4 Key Commands

Once your .env file is in place, open your terminal in the project folder and run these straightforward commands:

1.

Install Project Dependencies

pnpm install

Downloads Next.js, Drizzle ORM, date-fns-tz, and other required libraries.

2.

Apply Raw SQL Migrations & Seed Schedule

pnpm run db:migrate && pnpm run db:seed

Applies 0001_init.sql (including btree_gist and the GIST exclusion constraint, which drizzle-kit push would skip) and seeds demo-host.

3.

Run Automated Verification Tests (Gate 1 & Gate 2)

pnpm test

Runs Gate 1 (pure timezone/DST & .ics unit tests) and Gate 2 (boundary loader & 5-way race-condition concurrency suite against an isolated test DB).

4.

Start Your Local App

pnpm run dev

Launches the local web server. Open http://localhost:3000/demo-host/30-min-intro in your browser to test your booking page live.

PART 06 // EXTENDING THE SYSTEM

Part 6: Here's How We Can Refine Things and Make It Better

Once your core booking engine is running, you have a solid architectural base that can be customized to match your exact business needs without paying monthly per-seat SaaS tolls:

1. Automated Email Confirmations

Connect an email service like Resend or Postmark. When an appointment is booked, automatically send an email to both host and guest with the generated .ics calendar file attached. Modern email clients will display an instant "Add to Calendar" button.

2. Two-Way Google & Outlook Sync

Add OAuth connections so the scheduling engine automatically checks the host's existing Google or Outlook calendar for conflicts before offering available slots to guests.

3. Paid Bookings (Stripe Integration)

For consulting calls or coaching sessions, insert a Stripe Checkout step into the booking flow so the slot is only finalized after successful payment confirmation.

4. Custom Intake Questions

Expand the guest details form to collect phone numbers, project descriptions, or qualification questions, storing the responses directly alongside the booking record.

PART 07 // PRODUCTION DEPLOYMENT

Part 7: Here's How to Deploy

Taking your booking engine live is straightforward and takes less than 10 minutes:

1. Connect Your Code to a Host (Vercel, Railway, or DigitalOcean)

Push your code to GitHub and connect the repository to a modern host like Vercel, Railway, or DigitalOcean App Platform. Every time you push a git commit, your site will build and deploy automatically.

2. Add Your Environment Variables

In your hosting provider's dashboard, add your database connection string and your live domain:

DATABASE_URL="postgres://user:[email protected]/neondb?sslmode=require"
NEXT_PUBLIC_APP_URL="https://schedule.yourdomain.com"

3. Verify Your Live Booking Page

Navigate to your live domain, book a test appointment, and confirm that the downloaded .ics invite opens properly in Apple Calendar, Google Calendar, or Outlook. You now have your own self-hosted, scalable scheduling engine.

PART 08 // MANAGED DEPLOYMENT & SECURITY

Part 8: Let PromptScale Help You with the Deployment

"Let PromptScale help you with the deployment. In one week we can have your project vetted, secure, and hosted with the domain of your choice."

Building a prototype with an AI agent is fast and fun, but ensuring production reliability, database connection pooling, automated backups, SSL certificates, and security hardening can be intimidating. If you’d rather focus on growing your business while seasoned engineers handle the infrastructure, our team is ready to step in.

Explore More Agent Blueprints

View All →
Copied to Clipboard!