# Build your own Calendly

> Source: https://buildyourown.software/like/calendly
> Category: Scheduling. Original vendor: Calendly LLC. Last updated 2026-09-10.
> This file is written for AI coding agents. It contains the context, the build prompt, and the test plan for a self-built replacement of Calendly.

## How to use this file

1. Read the whole file first.
2. Build the app described in "Build prompt". Treat its acceptance criteria as the definition of done.
3. When the build works, follow "Test prompt" to write and run automated tests.
4. Ask the user before changing the data model, the stack, or the non-goals.
5. The app must run the "one box" way described in "Default deployment" below: one process, SQLite, local disk, no hosted services unless the build prompt names one.

## What Calendly does

Calendly gives you a public link. Someone picks a time from your real availability, fills in their name and email, and a calendar event lands on both calendars with a video link. It sends confirmations and reminders and lets the guest reschedule or cancel.

On top of that it sells team features: round-robin assignment, collective availability, routing forms that decide who a lead should meet, and CRM sync. Those are the reason for the Teams and Enterprise tiers.

For one person or a small team, the whole product is: read free/busy from Google, apply some rules, write an event. That is a small app with one OAuth integration.

## What it costs

Typical spend: $1,920 per year. Calendly Teams for 10 people: $16 × 10 seats × 12 months on annual billing. $2,400 if billed monthly.

- Free: $0 1 seat
- Standard: $10 per seat / month billed annually ($12 monthly)
- Teams: $16 per seat / month billed annually ($20 monthly)
- Enterprise: $15k+ per year, minimum 50 seats (Contact sales. Starts at $15,000 per year.)

Prices checked 2026-09-10 at https://calendly.com/pricing.

## Features and whether to build them

- [build] Public booking page: A link per event type that shows available slots in the guest's timezone. This is the product.
- [build] Availability rules: Working hours per weekday, date overrides, buffers before and after, minimum notice, max per day. The rules are what make the page trustworthy. They're also just a config object.
- [build] Calendar sync (Google, Outlook): Read busy times from your calendars and write the booked event. One OAuth flow and two API calls. Start with Google; add Microsoft when someone asks.
- [build] Confirmation, reminder, and follow-up emails: Emails to host and guest at booking, before the meeting, and after. A confirmation email is required. Reminders are a scheduled job in the same process.
- [build] Reschedule and cancel links: Guest can move or cancel without emailing you. Saves you the back-and-forth that scheduling tools exist to remove.
- [build] Video conferencing links: Auto-generate a Google Meet or Zoom link on the event. Google Meet is a flag on the calendar event. Zoom needs another OAuth app; do it later.
- [build] Custom questions on the booking form: Ask for a phone number, company, or 'what do you want to talk about'. A JSON array of fields. Ten minutes.
- [build] Multiple event types: 15-min intro, 30-min call, 60-min working session, each with its own rules. Calendly locks this behind a paid plan. It's one database table.
- [maybe] Round-robin and collective events: Assign to the next available teammate, or find a slot where everyone is free. Only if you're a sales team distributing inbound demos. Otherwise skip.
- [skip] Routing forms: Ask questions, then route to the right person or event type. A form with a redirect. Build it when your inbound volume makes it worth having.
- [maybe] Payments at booking: Charge for the slot with Stripe or PayPal. Consultants and coaches, yes. Stripe Checkout before the event is created is about a day.
- [skip] CRM sync, SSO, audit logs: Push meetings to Salesforce or HubSpot; enterprise admin. A webhook on booking covers CRM sync. SSO is not a small-team problem.

## Under the hood

### Data model

- User (host): id, name, email, slug, timezone, password_hash, calendar_provider (local | google), google_refresh_token. The app issues its own sessions. Google is connected later, only for calendar access. Encrypt the refresh token at rest.
- Calendar connection: id, user_id, provider, calendar_id, role (check_busy | write_events). Check busy on several calendars; write events to one.
- Event type: id, user_id, slug, name, duration_min, description, location (meet | phone | in_person | custom), color, questions (json), active
- Availability: id, user_id, weekly_hours (json: weekday → [start,end][]), date_overrides (json), buffer_before_min, buffer_after_min, min_notice_hours, max_per_day, slot_interval_min
- Booking: id, event_type_id, host_id, guest_name, guest_email, guest_timezone, start_at, end_at, answers (json), status (confirmed | cancelled | rescheduled), calendar_event_id, meet_url, manage_token, created_at. manage_token is a random secret in the reschedule/cancel links.
- Notification: id, booking_id, kind (confirmation | reminder_24h | reminder_1h | followup), send_at, sent_at

### Key flows

**Compute available slots**
1. Guest opens /<host>/<event-type> and the page reads their browser timezone.
2. Server takes the requested date range (2 weeks) and expands weekly_hours plus overrides into candidate windows in the host's timezone.
3. Fetch busy intervals from the host's calendar provider (Google freebusy, or the local busy_blocks table) for every check_busy calendar, plus existing bookings.
4. Subtract busy intervals expanded by buffers; drop anything inside min_notice; enforce max_per_day.
5. Slice remaining windows into slots every slot_interval_min that fit duration_min; convert to guest timezone; return.

**Book a slot**
1. Guest picks a slot and submits name, email, and answers.
2. Server re-checks the slot is still free (race with another guest or a new calendar event).
3. Insert booking in a transaction with a unique index on (host_id, start_at) to prevent doubles.
4. Create the calendar event through the provider (Google with both attendees and a Meet link, or a local busy block); store the event id and Meet URL.
5. Queue confirmation emails to host and guest with an .ics attachment and the manage link.
6. Schedule reminder notifications at start_at minus 24h and minus 1h.

**Reschedule or cancel**
1. Guest opens /manage/<manage_token>.
2. Cancel: mark cancelled, delete the calendar event, email both parties, drop pending reminders.
3. Reschedule: show the slot picker again; on pick, create a new booking, mark the old one rescheduled, update the calendar event.

**Send reminders**
1. A croner job inside the app process runs every minute and takes a row in job_locks first so two instances never overlap.
2. Select notifications where send_at ≤ now and sent_at is null and the booking is confirmed.
3. Send through SMTP_URL, or write an .eml to ./data/outbox in dev; stamp sent_at.

### Integrations

- SQLite on local disk (required): Bookings, event types, availability, sessions, job locks. One file at ./data/app.db.
- Google Calendar API (optional): OAuth, freebusy lookup, event create/update/delete, Meet links. A local provider backed by a busy_blocks table covers dev and testing.
- SMTP relay (optional; outbox in dev) (optional): Confirmation, reminder, and cancellation emails through SMTP_URL. Without it mail goes to ./data/outbox.
- croner in-process scheduler (required): Reminder delivery and retention, guarded by a job_locks table.
- Microsoft Graph (optional): Outlook calendar support.
- Zoom API (optional): Zoom links instead of Meet.
- Stripe Checkout (optional): Paid bookings.
- Outbound webhook (optional): Post bookings to your CRM or Slack.

## Where to build it

- Claude Code (https://claude.com/claude-code): Best fit. The slot algorithm and timezone handling benefit from an agent that can write and run tests as it goes.
- Replit (https://replit.com): Fine for building and trying it on the local calendar provider. Then move the folder to a VPS and run docker compose up -d.
- Lovable (https://lovable.dev): Skip it, or use it only for the booking page UI. Lovable is built around Supabase and this app runs on SQLite in one process.
- ChatGPT / Codex (https://chatgpt.com): Ask ChatGPT to adapt the availability rules to your week first, then send the spec to Codex.
- Stripe (https://stripe.com): Only if you charge for time. Add Stripe Checkout between 'pick a slot' and 'create the event'.

---

## Default deployment

Also at https://buildyourown.software/deploy.md. The build prompt below assumes this.

Every web app on buildyourown.software is written to run the same way. Learn it once and every tool's build prompt makes sense.

## The shape

- **One process.** Node 22 running the app. A second process only when the tool genuinely needs one (a job worker, a probe runner). Never a serverless function.
- **SQLite.** `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations checked into the repo and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- **Local disk for files.** Uploads live under `./data/files/<table>/<id>/`. The app serves them through an authenticated route with short-lived signed URLs it mints itself. No bucket.
- **In-process scheduler.** `croner` inside the app process for reminders, retention, digests, and polling. A row in a `job_locks` table stops two instances from running the same job.
- **Sessions the app issues itself.** On first run the app creates an admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` (or prompts in the browser if unset). Teammates join through invite links. Sessions are signed, httpOnly cookies. Google sign-in is an optional add-on, never a requirement.
- **Realtime from the process.** Server-sent events fed by an in-memory event bus. No database change feeds, no websocket service.
- **Email through any SMTP server.** `nodemailer` with `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log, so every flow works on a laptop with no account. When you go public, point `SMTP_URL` at the one relay you choose. Sending straight from a VPS lands in spam, so this is the one hosted service most tools keep.
- **UTC in the database,** local time in the UI, `date-fns-tz` for every conversion.

## Running it

- **Laptop:** `pnpm install && pnpm dev`. That's the whole install. No env vars are required; every setting has a default that works locally.
- **Public:** a VPS with 1 CPU and 1 GB RAM, one DNS record, and `docker compose up -d`. The compose file has two services: `app` and `caddy`. Caddy terminates TLS with a certificate it gets on its own. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. A `backup` service in compose runs it nightly. Restoring is copying one file.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## What this rules out

No Vercel, Supabase, Neon, Clerk, Auth0, R2, S3, Fly, Cloudflare Workers, Upstash, or any service that needs an account to run the app. If a tool can't avoid an outside service (a calendar OAuth app, a GitHub webhook, an SMTP relay, a second probe location), the build prompt says so in one line and makes it optional or last.

## Acceptance criteria every tool inherits

- A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
- `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
- Killing the process mid-job and restarting it doesn't double-run or lose the job.
- `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
- Every outbound email in dev shows up in `./data/outbox`.

---

## Build prompt

Also available on its own at https://buildyourown.software/like/calendly/build-prompt.md

# Build a scheduling app (replacing Calendly)

You are building a self-hosted scheduling app for one host, extensible to a small team. It replaces Calendly for people who need a public booking page tied to their Google Calendar with reminders and reschedule/cancel links. Build it end to end. Correctness around timezones and double-booking matters more than features.

## Stack

- One Node 22 process running Next.js (App Router) with TypeScript and Tailwind. Nothing runs outside that process except the optional outside services below.
- SQLite via `better-sqlite3` with Drizzle, WAL mode, file at `./data/app.db`. Migrations are checked in and run on startup. Keep the schema portable so `DATABASE_URL=postgres://...` works later, but don't build for it now.
- Files on local disk under `./data/files/<table>/<id>/`, served through an authenticated route with short-lived signed URLs the app mints itself.
- `croner` inside the app process for reminders and retention. A row in a `job_locks` table stops two instances from running the same job.
- Sessions the app issues itself: signed httpOnly cookies. First run creates the admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD`, or prompts in the browser if unset. Teammates join through invite links.
- Server-sent events from an in-memory bus for anything live in the UI.
- Email with `nodemailer` through `SMTP_URL`. Without it, mail is written to `./data/outbox/*.eml` and printed to the log. Generate `.ics` attachments with the `ics` package.
- Google Calendar API via `googleapis` (OAuth 2.0, offline access). This is the one outside service the tool can't avoid for reading real free/busy and writing real events. Behind the same `lib/calendar/` interface, ship a `local` provider backed by a `busy_blocks` table so the whole booking flow, slot algorithm, and emails run with no Google account. `local` is the default until `GOOGLE_CLIENT_ID` is set.
- Any key the app needs (session signing, refresh token encryption) is generated on first run and stored in `./data/keys.json` if not given in env.
- UTC in the database, local time in the UI, `date-fns` and `date-fns-tz` for every conversion. Never do timezone math by hand.
- `zod` for every request body and every JSON column.

Full profile: https://buildyourown.software/deploy.md

## Running it

- **Laptop:** `pnpm install && pnpm dev`, open `http://localhost:3000`. No env vars. The first visit redirects to `/setup` to create the admin. The local calendar provider and the outbox mean no Google account and no mail account are needed to try every flow.
- **VPS:** 1 CPU, 1 GB RAM, one A record. Put `APP_URL`, and optionally `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `SMTP_URL`, in `.env`, then `docker compose up -d`. Compose runs `app`, `caddy` (TLS on its own), and `backup`. `./data` is a named volume.
- **Backups:** `pnpm backup` runs `sqlite3 .backup` into `./data/backups/<date>.db` and prunes to the last 14. The `backup` service runs it nightly. Restore by copying one file over `./data/app.db` and restarting.
- **Updates:** `git pull && docker compose up -d --build`. Migrations run on boot.

## Data model

All tables have `id` (text, uuid v4), `created_at`, `updated_at`. Every timestamp is text in ISO 8601 UTC with milliseconds (`2026-09-15T14:00:00.000Z`) so string comparison equals time comparison. Booleans are integer 0/1. JSON is text, parsed and validated with zod on read. There is no `timestamptz` and no `jsonb`: the app formats every date before it hits the database and never queries inside JSON. The partial unique index below is a normal SQLite partial index.

- `hosts`: `email` (unique), `name`, `slug` (unique, url-safe), `timezone` (IANA), `password_hash` (argon2), `role` (`admin` | `member`), `calendar_provider` (`local` | `google`, default `local`), `google_refresh_token` (encrypted, nullable), `write_calendar_id`, `avatar_path` (relative to `./data/files`, nullable).
- `sessions`: `host_id`, `token_hash`, `expires_at`, `last_seen_at`.
- `invites`: `email`, `token_hash` (unique), `invited_by`, `expires_at`, `accepted_at` (nullable).
- `calendar_connections`: `host_id`, `provider` (`local` | `google`), `calendar_id`, `summary`, `check_busy` (bool).
- `busy_blocks`: `host_id`, `calendar_id`, `title`, `start_at`, `end_at`, `source` (`manual` | `booking`), `booking_id` (nullable). The local provider's whole calendar. Index on (`host_id`, `start_at`).
- `event_types`: `host_id`, `slug`, `name`, `description`, `duration_min`, `location_kind` (`meet` | `phone` | `in_person` | `custom`), `location_detail`, `color`, `questions` (json text: array of `{ id, label, type: text|textarea|phone|select, required, options? }`), `active` (bool). Unique on (`host_id`, `slug`).
- `availability`: one row per host. `weekly_hours` (json text: `{ mon: [["09:00","12:00"],["13:00","17:00"]], ... }` in the host's timezone), `date_overrides` (json text: `{ "2026-12-24": [] , "2026-12-26": [["10:00","14:00"]] }`), `buffer_before_min`, `buffer_after_min`, `min_notice_hours`, `max_per_day`, `slot_interval_min` (default 30), `booking_window_days` (default 60).
- `bookings`: `event_type_id`, `host_id`, `guest_name`, `guest_email`, `guest_timezone`, `start_at`, `end_at`, `answers` (json text), `status` (`confirmed` | `cancelled` | `rescheduled`), `rescheduled_to_id` (nullable), `calendar_event_id`, `meet_url` (nullable), `manage_token` (32 random bytes, base64url, unique), `cancel_reason`. Partial unique index on (`host_id`, `start_at`) where `status = 'confirmed'`.
- `notifications`: `booking_id`, `kind` (`confirmation_guest` | `confirmation_host` | `reminder_24h` | `reminder_1h` | `cancelled` | `rescheduled`), `send_at`, `claimed_at` (nullable), `sent_at` (nullable), `attempts` (int, default 0), `error` (nullable).
- `job_locks`: `name` (primary key), `locked_by`, `locked_at`, `expires_at`.

## Calendar providers (`lib/calendar/`)

One interface: `listCalendars()`, `freeBusy(calendarIds, from, to)`, `createEvent(input)`, `patchEvent(id, input)`, `deleteEvent(id)`. Two implementations:

- `google.ts`: `googleapis` with the host's refresh token. `createEvent` requests a Meet link through `conferenceData` when `location_kind = meet` and returns `hangoutLink`.
- `local.ts`: reads and writes `busy_blocks`. `listCalendars` returns one calendar named "Local". `createEvent` inserts a `busy_blocks` row with `source = booking` and returns its id; `meet_url` is null and the confirmation shows `location_detail` instead.

Pick the provider from `hosts.calendar_provider`. A host can switch to `google` only after connecting it on the Calendars screen.

## Screens

### Host (authenticated)

1. **Setup and sign in**: `/setup` runs once when there are no hosts. It creates the admin from `ADMIN_EMAIL` and `ADMIN_PASSWORD` if both are set, otherwise shows a form for name, email, password, and timezone. `/login` is email and password. `/invite/<token>` lets a teammate set a name and password and become a host with a slug from their email local part. Admins create invites from Settings.
2. **Dashboard (`/app`)**: upcoming bookings (next 14 days) with guest, event type, time in host timezone, and links to the calendar event and the manage page. Past bookings below. Subscribes to `/api/stream` (SSE) so a new booking appears without a refresh.
3. **Event types (`/app/event-types`)**: list, create, edit, toggle active, copy link. Editor has name, slug, duration, description, location, color, and a question builder.
4. **Availability (`/app/availability`)**: weekly hours editor (per weekday, multiple ranges), date overrides with a calendar picker, buffers, minimum notice, max per day, slot interval, booking window.
5. **Calendars (`/app/calendars`)**: with the local provider, a busy block editor (title, start, end) and the single "Local" calendar. A "Connect Google Calendar" button (shown only when `GOOGLE_CLIENT_ID` is set) starts OAuth for `calendar.readonly` and `calendar.events`, stores the refresh token encrypted, and switches the provider. Once connected, list Google calendars, tick which to check for busy time, and pick one to write events to.
6. **Settings**: name, slug, timezone (searchable IANA list), password, avatar upload (stored at `./data/files/hosts/<id>/`, served through the signed-URL route), invite teammates (admin only), sign out, delete account.

### Guest (public)

7. **Host page (`/<slug>`)**: name, avatar, list of active event types with duration.
8. **Booking page (`/<slug>/<event-slug>`)**: month calendar on the left showing days with availability, time slots for the selected day on the right, in the guest's timezone (auto-detected, changeable via a dropdown). Also readable as `?date=2026-09-15&month=2026-09`. Show a 12h/24h toggle.
9. **Booking form**: name, email, custom questions, then "Confirm". On success show a confirmation with the time in both timezones, the Meet link or location, "add to calendar" links (Google, Outlook, .ics), and a link to manage the booking.
10. **Manage page (`/manage/<token>`)**: booking details with Reschedule and Cancel. Reschedule opens the slot picker; cancel asks for an optional reason.

## Slot algorithm (put this in `lib/slots.ts`, pure, fully unit-tested)

Input: event type, availability, list of busy intervals (UTC), existing confirmed bookings, `from`/`to` range (UTC), `now`.

1. For each day in the range in the host timezone, take `date_overrides[date]` if present, else `weekly_hours[weekday]`. Convert each range to UTC intervals. Handle days where DST changes.
2. Build the busy set: calendar busy intervals plus confirmed bookings, each expanded by `buffer_before_min` before and `buffer_after_min` after.
3. Subtract busy from available.
4. Remove anything starting before `now + min_notice_hours`.
5. Slice each remaining window into candidate starts every `slot_interval_min`, keeping only starts where `start + duration_min` is still inside the window.
6. Drop days that already have `max_per_day` confirmed bookings.
7. Return slots as UTC ISO strings. The UI converts to the guest timezone.

## Booking flow (`POST /api/book`)

1. Validate input with zod. Reject unknown event type, inactive event type, past start, and answers missing required questions.
2. Recompute available slots for that single day and confirm the requested start is in the list. Use a fresh `freeBusy` call through the host's provider.
3. In one transaction: insert the booking with `status = confirmed`. If the unique index fails, return 409 with "That time was just taken".
4. Call `createEvent` on the host's provider for `write_calendar_id`: title `"<event type name> with <guest name>"`, description with answers and the manage link, attendees host and guest, a Meet link when `location_kind = meet` and the provider is Google. Store `calendar_event_id` and `meet_url`.
5. Insert notifications: confirmation to guest and host (send_at now), `reminder_24h` and `reminder_1h` (skip any that are already in the past).
6. Publish `booking.created` on the event bus and return the booking id and manage token.

If step 4 fails, mark the booking cancelled with reason "calendar error" and return 502. Never leave a booking without a calendar event.

## Cancel and reschedule

- Cancel: set `status = cancelled`, call `deleteEvent`, insert a `cancelled` notification, null out `send_at` on unsent reminders.
- Reschedule: run the booking flow for the new time creating a new booking; on success set the old booking to `rescheduled` with `rescheduled_to_id`, call `patchEvent` on the existing calendar event's start/end instead of creating a second one, and move `calendar_event_id` to the new booking. Send a `rescheduled` notification.

## Jobs (`lib/jobs/`, started once in `instrumentation.ts`)

- Every job takes the `job_locks` row for its name first: insert, or update where `expires_at < now`, with a 10-minute expiry and a heartbeat every 30 seconds. If the row is held, skip this tick. Release it when done.
- `notifications`, every minute: select up to 100 notifications with `sent_at IS NULL AND send_at <= now AND (claimed_at IS NULL OR claimed_at < now - 10 min) AND attempts < 5` whose booking is `confirmed` (or whose kind is `cancelled` / `rescheduled`). Stamp `claimed_at` and bump `attempts` in one update before sending anything.
- Render an email per kind. Confirmation emails attach an `.ics` file. Every email shows the time in the recipient's timezone and includes the manage link (guest) or calendar link (host).
- Send with `lib/mail.ts` (nodemailer, or the outbox when `SMTP_URL` is unset). On success stamp `sent_at`; on failure store `error` and clear `claimed_at` so it retries next tick.
- `retention`, nightly: delete sessions past `expires_at` and invites past `expires_at` with no `accepted_at`.

## Non-goals

No team accounts, round-robin, collective events, routing forms, payments, SMS, Outlook, Zoom, or CRM sync in this build. No hosted services beyond a Google Cloud OAuth app for Calendar and an SMTP relay. Design the schema so a `team_id` on hosts and a `hosts[]` on event types can be added later.

## Acceptance criteria

1. The admin signs in, connects Google from the Calendars screen (or stays on the local provider), sees their calendars, and can pick busy-check calendars and a write calendar.
2. With weekly hours Mon–Fri 09:00–17:00 in `America/New_York`, a 30-minute event, and no busy time, the booking page shows 16 slots on a weekday and none on Saturday.
3. A busy event 10:00–11:00 with 15-minute buffers removes the 09:30, 10:00, 10:30, and 11:00 slots.
4. `min_notice_hours = 4` hides slots within the next four hours.
5. A date override to `[]` hides every slot that day; an override to a single range shows only that range.
6. Slots render correctly for a guest in `Asia/Kolkata` (half-hour offset) and for a host week that crosses a DST change.
7. Two concurrent `POST /api/book` for the same slot produce exactly one confirmed booking and one 409.
8. A booking creates a calendar event (a `busy_blocks` row on the local provider, a Google event with a Meet link on Google), and both guest and host receive a confirmation email with a valid `.ics`.
9. Cancelling via the manage link deletes the calendar event and prevents pending reminders from sending.
10. Rescheduling updates the existing calendar event instead of creating a new one and sends a rescheduled email.
11. The notifications job sends due notifications exactly once, even when two ticks overlap.
12. The public booking page works with JavaScript disabled for browsing days (links) and with JS for the slot picker.
13. A fresh clone runs with `pnpm install && pnpm dev` and no env vars, and the first browser visit creates the admin.
14. `docker compose up -d` on a clean Ubuntu VPS with one A record serves the app over HTTPS with no other setup.
15. Killing the process mid-job and restarting it doesn't double-run or lose the job.
16. `pnpm backup` then deleting `./data/app.db` and restoring from the backup brings every record back.
17. Every outbound email in dev shows up in `./data/outbox`.

## Deliverables

- The app, migrations, a seed script with one host, three event types, a few busy blocks, and a week of sample bookings.
- `lib/slots.ts` with a table-driven test suite covering criteria 2 through 6.
- `docker-compose.yml` (`app`, `caddy`, `backup`), `Caddyfile`, and `scripts/backup.sh` wired to `pnpm backup`.
- README covering the laptop run, the VPS run, restore from backup, Google Cloud OAuth setup (consent screen, scopes, redirect URI), and `SMTP_URL`.

Build the slot algorithm and its tests first. Then the local calendar provider and the booking flow. Then the screens and jobs. Then the Google provider. Run the app in the browser after each step.

---

## Manual test checklist

- [ ] Run pnpm dev with no env vars, open localhost:3000, and confirm the first visit creates the admin.
- [ ] Connect a Google account from the Calendars screen and confirm the OAuth consent shows only calendar scopes.
- [ ] Put a busy event on your calendar and confirm the slot disappears from the booking page.
- [ ] Set buffers of 15 minutes and confirm slots adjacent to a busy event are removed.
- [ ] Set a date override to 'unavailable' for tomorrow and confirm no slots show.
- [ ] Open the booking page from a browser set to a different timezone and confirm times shift correctly.
- [ ] Book a slot and confirm a calendar event with a Meet link appears on both calendars.
- [ ] Confirm the guest gets a confirmation email with a working .ics attachment (in ./data/outbox when SMTP_URL is unset).
- [ ] Book the same slot from two browsers at once and confirm only one succeeds.
- [ ] Use the manage link to reschedule, then confirm the old event is updated, not duplicated.
- [ ] Use the manage link to cancel, then confirm the calendar event is removed and reminders do not send.
- [ ] Wait for (or fake the clock to) the 1-hour reminder and confirm it sends exactly once.
- [ ] Book across a daylight-saving boundary and confirm the times are right on both sides.
- [ ] Run `docker compose up -d` on a fresh VPS and open the domain over HTTPS.

## Test prompt

Also available on its own at https://buildyourown.software/like/calendly/test-prompt.md

Write automated tests for the scheduling app in this repo. Treat the acceptance criteria below as the spec. Use Vitest for the slot algorithm, jobs, and API routes, and Playwright for guest booking flows. Every test file gets its own on-disk temp SQLite file (`./data/test/<file>-<pid>.db`), migrated in `beforeAll` and deleted in `afterAll`. The only things you mock are the Google Calendar client and the SMTP transport, both at the module boundary; record one real-shaped freebusy and events.insert response as fixtures. Run everything else for real: the local calendar provider, the outbox, the job lock.

## Acceptance criteria to cover

1. First visit with no hosts creates the admin; sign-in sets a signed cookie; connecting Google (mocked) stores an encrypted refresh token and lists calendars; without Google the Calendars screen shows the local provider.
2. Weekly hours Mon–Fri 09:00–17:00 `America/New_York`, 30-minute event, no busy time: 16 slots on a weekday, 0 on Saturday.
3. Busy 10:00–11:00 with 15-minute buffers removes 09:30, 10:00, 10:30, 11:00.
4. `min_notice_hours = 4` hides slots inside the next four hours relative to an injected `now`.
5. Date override `[]` hides all slots that day; override `[["10:00","14:00"]]` shows only those.
6. Guest in `Asia/Kolkata` sees correct half-hour-offset times; a host week spanning the US DST change in March and November produces correct UTC starts on both sides.
7. Two concurrent bookings for the same slot: exactly one confirmed booking and one 409.
8. On the local provider a booking inserts a `busy_blocks` row with `source = booking`; on Google it calls `events.insert` with both attendees and `conferenceDataVersion = 1` and stores the returned `hangoutLink`. Either way it queues two confirmation notifications plus two reminders, and the confirmation `.eml` carries a parseable `.ics`.
9. Cancelling deletes the calendar event (busy block or `events.delete`) and nulls `send_at` on unsent reminders.
10. Rescheduling calls `patchEvent` once, never `createEvent`, and creates a `rescheduled` notification.
11. Running the notifications job twice in a row sends each due notification once (count `.eml` files in the outbox).
12. Guest booking page: pick a day, pick a slot, fill the form, see the confirmation with manage link (Playwright).
13. Booting with an empty `./data` and no env vars creates `app.db`, runs migrations, and redirects the first request to `/setup`.
14. `docker compose config` validates and lists `app`, `caddy`, and `backup`; the Caddyfile references the `app` service. (Static check; the real HTTPS check is manual.)
15. Job lock: two job runners started at once for `notifications` take the lock exactly once; a runner killed mid-tick leaves a `job_locks` row that expires, and the next tick re-claims it and retries rows with a stale `claimed_at` without double-sending.
16. `pnpm backup` writes `./data/backups/<date>.db`; deleting `app.db` and restoring it brings back every host, event type, booking, and notification row.
17. With `SMTP_URL` unset, sending mail writes one `.eml` per message to `./data/outbox` with the right `To` and `Subject`.
18. A guest who submits a slot that was taken between page load and submit sees the "just taken" message and a refreshed slot list.
19. `max_per_day = 2` hides all slots on a day that already has two confirmed bookings.

## Layout

- `tests/unit/slots.test.ts`: table-driven cases for 2, 3, 4, 5, 6, 19. Each case is `{ name, availability, eventType, busy, bookings, now, guestTz, expectStarts }`.
- `tests/integration/`: API routes, jobs, and scripts against the temp SQLite file for 1, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17. Use `Promise.all` with two requests for criterion 7 and two runner instances for criterion 15.
- `tests/e2e/`: Playwright for 12 and 18, on the local calendar provider with the outbox, so no mocks are needed.

## Rules

- Name every test after its criterion: `test("AC7: concurrent bookings produce one confirmed and one 409")`.
- Freeze time with `vi.useFakeTimers()` or an injected clock; never depend on the real date.
- Point every test at its own `DATABASE_PATH` and its own `./data/test/<file>/outbox` so files run in parallel without touching each other.
- Add `pnpm test` and a GitHub Actions workflow that runs it. No services needed: SQLite is a file and the outbox is a folder.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass/fail and what changed.
