> From crmmeister.com/blog/build-your-own-calendly (Ahmed Essam, October 2026).
> This is the spec I handed over on 17 October 2026, unchanged except that dashes were replaced
> and my time zone is shown as [MY_TIMEZONE].
>
> What changed before the build started:
>
> - Design: section 8 was replaced by my own site's design system (light, sans serif, 1 accent).
> - Account: a Google Workspace account instead of a personal Gmail, with an Internal OAuth app,
>   so the 7-day token limit for external apps in Testing does not apply.
> - Mail: no .ics attachments; Google's own invitation carries the calendar file.
> - Scope cut to v2: analytics, appearance editor, group events, single-use links, floating badge,
>   multiple duration options, ICS feeds, multi-account OAuth (added later as conflict-only accounts).
> - Tailwind and React islands were dropped for plain TypeScript on the public page.

# Build "Book": a personal, single-owner scheduling app (Calendly for one person)

You are building a production-quality scheduling app for ONE owner (me). No teams, no multi-tenant accounts, no public sign-up. Invitees never log in. Read this whole spec first, then:

1. Propose a short implementation plan (phases, folder structure, data model, key libraries) and wait for my OK.
2. Build phase by phase. At the end of each phase, run the tests, tell me what works, and tell me exactly what I need to do by hand (Google Cloud console, Cloudflare, Supabase, DNS).
3. Ask me before adding any dependency or service not listed here.

---

## 1. Stack and hosting

- **Framework:** Astro (SSR, `@astrojs/cloudflare` adapter) with React islands for interactive parts. TypeScript strict.
- **Hosting:** Cloudflare Workers/Pages on a subdomain, e.g. `book.[MYDOMAIN].com`. My main site is Astro on Cloudflare too, so keep the embed script framework-agnostic.
- **Database + auth:** Supabase (Postgres, Row Level Security on every table, Supabase Auth for the admin only).
- **Scheduled jobs:** Cloudflare Workers Cron Triggers (reminders, webhook renewal, cleanup).
- **Bot protection:** Cloudflare Turnstile (invisible mode) + honeypot field + per-IP rate limiting.
- **Google APIs:** call the REST endpoints with `fetch`. Do NOT use the `googleapis` Node SDK (it doesn't run well on Workers).
- **Dates/timezones:** store everything in UTC. Use the Temporal polyfill or `date-fns-tz`. DST must be handled correctly.
- **Styling:** Tailwind with a design-token layer (CSS custom properties) so the whole look can be re-themed from the admin.
- **Tests:** Vitest. The slot-generation engine must have thorough unit tests.

## 2. Google integration (the core)

**Accounts and calendars**
- My main account is a personal Gmail. I need to connect **multiple Google accounts** via OAuth (offline access, refresh tokens).
- For each connected account, list its calendars and let me toggle which ones count as **conflict calendars**.
- I pick ONE **destination calendar** where bookings are written (default: primary calendar of my main Gmail).
- Also support adding **read-only ICS feed URLs** as conflict sources (for Outlook/iCloud/other calendars). Cache and refresh them every 15 minutes.
- Encrypt refresh tokens at rest (AES-GCM with a key from environment secrets). Show connection health in the admin and prompt me to reconnect if a token fails.

**Scopes:** `calendar.events`, `calendar.readonly` (or `calendar.freebusy`), `gmail.send`, `openid email profile`.

**Important:** in the Google Cloud OAuth consent screen, the app must be set to **"In production"** (not "Testing"), otherwise refresh tokens expire every 7 days. It's fine that it stays unverified since only I connect accounts. Include this in the setup checklist you give me.

**Availability check**
- Use `freeBusy.query` on every enabled conflict calendar across all accounts, plus cached ICS busy blocks.
- Cache free/busy results for 60 seconds per range.
- Re-check free/busy at the moment of booking submission (never trust the slot list the client loaded).

**Creating the booking**
- Insert the event on the destination calendar with `conferenceDataVersion=1` and `conferenceData.createRequest` (`hangoutsMeet`) to auto-generate a **Google Meet** link.
- Use `sendUpdates=all` so Google sends the calendar invite to the invitee **from my own email**.
- Event title, description, and attendee list come from the event-type template (variables: `{invitee_name}`, `{event_name}`, `{answers}`, `{reschedule_link}`, `{cancel_link}`).

**Emails from my address**
- Confirmation, reminder, reschedule, cancellation, and optional follow-up emails are sent via the **Gmail API (`gmail.send`) from my Gmail**, as HTML + plain text.
- Each email includes an `.ics` attachment so non-Google users can add it too.
- Fallback: if Gmail send fails, log it and show the failure in the admin.

**Two-way sync (Phase 3)**
- Subscribe to Google push notifications (`events.watch`) on the destination calendar. Renew channels before they expire via cron.
- If I move or delete the event in Google Calendar, update the booking in the app.
- If the invitee declines in Google Calendar, mark the booking as declined and free the slot.

## 3. Event types (match Calendly)

Each event type has:
- Name, URL slug, description (rich text), color, active/inactive toggle.
- **Kind:** one-on-one (default) or group (one host, many invitees in the same slot, with a seat limit).
- **Duration:** a single fixed length, or multiple options the invitee chooses from (e.g. 15/30/45/60, custom values allowed).
- **Location:** Google Meet (default). Leave the model extensible for phone / in-person / custom link later.
- **Availability schedule:** pick one of my named schedules (see §4).
- **Buffer before** and **buffer after** (minutes). Buffers must not overlap other events or other buffers.
- **Start-time increment** (e.g. slots every 15/30/60 min).
- **Minimum notice** (e.g. no bookings within 4 hours).
- **Booking window:** rolling X days into the future / fixed date range / indefinitely.
- **Max bookings per day** for this event type, plus a global daily cap across all types.
- **Custom invitee questions:** short text, long text, single select, multi select, phone, checkbox; required/optional; reorderable.
- Allow invitee to add guests (emails), on/off.
- **Secret** event types (not listed on my public page, only reachable by link).
- **Single-use links:** generate a one-time link for an event type that expires after one booking.
- Confirmation behavior: show the built-in confirmation page or redirect to a custom URL (pass booking details as query params).
- Reminder settings per event type (default: 24h and 1h before; editable; on/off).
- Optional follow-up email X hours after the meeting.
- All bookings confirm **instantly** (no approval flow).

## 4. Availability

- Multiple **named schedules** (e.g. "Work hours", "Evenings"), each with my timezone (default `[MY_TIMEZONE]`, editable).
- Per weekday: multiple time ranges (e.g. 09:00-12:00 and 14:00-18:00), or unavailable.
- **Date overrides:** set custom hours for a specific date, or mark a date/range as unavailable (vacations, holidays).
- Slot engine inputs: schedule, overrides, conflict busy blocks, existing bookings, buffers, increment, duration, minimum notice, booking window, daily caps.
- Slot engine must be a pure, well-tested module. Tests must cover: buffers on both sides, overlapping busy blocks, DST transitions in both my timezone and the invitee's, overrides, daily caps, minimum notice crossing midnight, group seats.

## 5. Public pages (invitee side)

- `/`: my public profile page: photo, name, short bio, list of active non-secret event types.
- `/[event-slug]`: booking page.
- **Timezone:** auto-detect the invitee's timezone from the browser and show all slots in it. Visible timezone switcher (searchable). 12h/24h toggle (default based on locale).
- Flow: month calendar (days with availability highlighted) → time slots for the selected day → details form (name, email, custom questions, guests) → confirm → confirmation page.
- Confirmation page: summary, Meet link, add-to-calendar buttons (Google, Outlook, Apple/.ics), reschedule and cancel links.
- **URL prefill:** `?name=&email=&a1=` and capture `utm_*` params, stored on the booking.
- **Reschedule:** secure tokenized link, allowed freely any time before the meeting starts. Rescheduling PATCHES the same Google event (so the existing invite updates), and sends a reschedule email.
- **Cancel:** secure tokenized link, optional reason, deletes the Google event with `sendUpdates=all`, sends cancellation email.
- Prevent double booking: transactional insert with an overlap constraint (Postgres exclusion constraint on a `tstzrange`) plus the live free/busy re-check.
- Fully accessible (keyboard navigation through the calendar and slots, ARIA, focus states, WCAG AA contrast). Mobile-first.

## 6. Embeds and widgets

Ship `embed.js` (vanilla JS, < 10 KB) served from the subdomain that supports:
- **Inline:** `<div data-book-inline="30-min-intro"></div>` renders an auto-height iframe.
- **Popup button:** any element with `data-book-popup="slug"` opens a modal.
- **Floating badge:** a corner button that opens the popup.
- Options via data attributes: hide profile header, accent color, light/dark/auto theme, prefill fields, UTM passthrough.
- Use `postMessage` for auto-resize and to emit events to the host page (`book:date_selected`, `book:booked`, `book:rescheduled`).
- Dedicated `/embed/[slug]` route with a stripped layout.
- Admin has an **embed code generator** with live preview and copy buttons for each mode, including a snippet for my Astro site.

"Easily adjustable widgets and components" means:
- Build the booking UI from small, documented components (Calendar, SlotList, TimezoneSelect, BookingForm, EventCard, ConfirmationCard).
- All visual decisions flow from design tokens (colors, fonts, radii, spacing scale, layout variant).
- Admin "Appearance" page: edit tokens with live preview, pick a layout variant (calendar left / calendar top / list-only), upload logo and photo.

## 7. Admin (owner only)

- Supabase Auth magic link, restricted to an allowlist of my email(s) via env var. Everything else returns 404.
- **Dashboard:** upcoming bookings, today's agenda, quick stats.
- **Bookings:** list with filters (upcoming/past/cancelled, event type, search). I can cancel or reschedule on the invitee's behalf, add private notes, resend confirmation.
- **Event types:** CRUD, duplicate, reorder, toggle active/secret, generate single-use links.
- **Availability:** schedules and date overrides with a visual weekly editor.
- **Calendars:** connected Google accounts, conflict toggles, destination calendar, ICS feeds, connection health.
- **Emails:** edit templates (subject + body with variables), preview, send test.
- **Webhooks:** add endpoints for `booking.created`, `booking.rescheduled`, `booking.cancelled`; HMAC-SHA256 signature header; delivery log with retry (so I can connect n8n, Zapier, or my CRM).
- **Analytics:** booking-page views, bookings, conversion rate per event type, cancellations/reschedules, top UTM sources, busiest days/times.
- **Appearance** and **Embed** pages (see §6).
- **Settings:** profile, timezone, default reminders, global daily cap, data export (CSV of bookings).

## 8. Design direction: editorial

The public pages should feel like a well-designed magazine, not a SaaS tool.
- **Typography-led.** A characterful serif for display (e.g. Fraunces, Newsreader, or Instrument Serif) paired with a crisp sans for UI (e.g. Geist or IBM Plex Sans). Large dates set like headlines; small caps / tracked uppercase labels.
- **Paper-and-ink palette:** warm off-white background, near-black ink, ONE confident accent (e.g. vermilion or deep cobalt). Full dark mode with the same character.
- **Strong grid,** generous whitespace, hairline rules as dividers, numbered sections ("01: Choose a day"), pull-quote style for my bio.
- Event types presented like article cards (title, standfirst, duration as a byline).
- Restrained motion: subtle fades and slides, respect `prefers-reduced-motion`.
- Avoid generic SaaS look: no default shadcn styling, no purple gradients, no rounded-everything.
- Admin can be simpler and utilitarian, but use the same type system.

## 9. Data model (suggested, refine in your plan)

`settings`, `google_accounts`, `calendars`, `ics_feeds`, `schedules`, `schedule_rules`, `date_overrides`, `event_types`, `event_type_questions`, `single_use_links`, `bookings`, `booking_answers`, `booking_guests`, `email_templates`, `email_log`, `webhooks`, `webhook_deliveries`, `page_views`, `google_watch_channels`.

Bookings store: start/end UTC, invitee timezone, status (confirmed/cancelled/rescheduled/declined), Google event id, Meet link, reschedule/cancel token (hashed), UTM data, reschedule history.

## 10. Phases

1. **Foundation:** project setup, Supabase schema + RLS, admin auth, Google OAuth for multiple accounts, calendar selection, slot engine with tests.
2. **Booking core:** event types, schedules, overrides, public booking flow, Meet event creation, Gmail emails with .ics, reschedule/cancel, double-booking protection, Turnstile + rate limiting.
3. **Polish and integrations:** editorial design system and appearance editor, embeds + generator, reminders and follow-ups via cron, webhooks, two-way Google sync, analytics, ICS feeds, single-use links, group events, CSV export.
4. **Hardening:** accessibility audit, error/empty/loading states, timezone edge-case review, deployment docs, a `SETUP.md` covering Google Cloud, Cloudflare, Supabase, DNS, and secrets.

## 11. Definition of done

- I can connect 2+ Google accounts and a booking is blocked by an event on any of them.
- An invitee in a different timezone sees correct local slots, books, and receives a Google invite from my Gmail with a Meet link.
- Buffers, minimum notice, booking window, and daily caps are all respected.
- The invitee can reschedule (same Google event updates) and cancel from the email links.
- Reminder emails arrive 24h and 1h before.
- The inline, popup, and floating embeds work on a plain HTML page and on my Astro site.
- Slot engine tests pass, including DST cases.
