Vibecode Acuity Scheduling
track this build4 phases, 12 steps, beginner friendly0%The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Acuity Scheduling earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Acuity Scheduling. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Acuity Scheduling · indie build A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Availability | Computed from calendar busy time, working hours and existing bookings | three sources that must agree before a slot is offered | | Reservation | Slots held inside the booking transaction, not checked then written | check-then-insert double-books under exactly the conditions that matter | | Time | Slots stored as instants, displayed in the booker's zone and yours | the two people are frequently in different places | | Payments | Hosted checkout with deposits, completed on webhook | someone who paid and closed the tab has still paid | | Notifications | Confirmations and reminders through a queue with retries | a missed reminder is a missed appointment | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the sync workers and the notification queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Slot reservation correctness depends on real transactions and row contention, which is exactly what this is for. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar account with real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break availability, and none appear in an empty test calendar. - Get it: A calendar with a month of realistic meetings including a recurring series with a cancelled instance. - Verify: Availability correctly excludes the series but includes the cancelled instance - [ ] **A payment provider in test mode** · free to test - Why: Deposits, webhook confirmation and refunds behave differently from a single charge, and the redirect failure only appears against a real flow. - Get it: Sign up, use test keys, configure a webhook, and test paying then closing the browser immediately. - Verify: A payment with the browser killed still produces a booking - [ ] **Your cancellation policy as data** · free - Why: A policy written in prose gets implemented twice slightly differently and the client sees whichever path ran. - Get it: Express it as bands of hours before the appointment and refund percentages, and decide what happens when you cancel. - Verify: The policy is a table of bands with an explicit rule for your own cancellations ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Acuity Scheduling, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Acuity Scheduling The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that cannot double-book you, whatever your calendar is doing to replace Acuity Scheduling. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Availability: Computed from calendar busy time, working hours and existing bookings · three sources that must agree before a slot is offered - Reservation: Slots held inside the booking transaction, not checked then written · check-then-insert double-books under exactly the conditions that matter - Time: Slots stored as instants, displayed in the booker's zone and yours · the two people are frequently in different places - Payments: Hosted checkout with deposits, completed on webhook · someone who paid and closed the tab has still paid - Notifications: Confirmations and reminders through a queue with retries · a missed reminder is a missed appointment ### Data model (create this before Phase 1) - services(id, name, duration_minutes, buffer_before, buffer_after, capacity, price_minor, cancellation_policy_id) - availability_rules(id, service_id, day_of_week, start_local, end_local, timezone, valid_from, valid_to) - calendars(id, provider, account_ref, sync_token, last_synced_at, direction) · direction is read, write or both - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) - bookings(id, service_id, starts_at, ends_at, client_id, state, created_at, external_event_id, payment_id) - holds(id, service_id, starts_at, expires_at, session_id) · counted against capacity while alive - A booking and its calendar event are linked by a stored identifier, so the tool can recognise its own events and never touch anyone else's. ### Phase 1 · Availability three sources agree on Build: Compute availability from calendar busy time, your working rules and existing bookings together, applying buffers before and after. Handle recurring calendar events with exceptions, declined invitations and all-day events. Trap: Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. Done when: Availability reflects calendar busy time, working rules and existing bookings together with buffers applied. Recurring events with exceptions are expanded correctly. Declined and all-day events follow stated rules. A calendar that has not synced recently narrows availability rather than offering slots optimistically. Do not build yet: No booking, holds, payments or notifications. ### Phase 2 · Booking a slot exactly once Build: Reserve the slot inside the transaction that creates the booking so concurrent attempts serialise. Test with genuinely concurrent requests. Return a clear result when the slot went while the client was filling the form. Trap: Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. Done when: Twenty concurrent requests for one slot produce exactly one booking, asserted in a test with real concurrency. A booking that loses the race returns a clear message and offers alternatives. A service with capacity greater than one accepts exactly that many. Cancelling a booking returns its slot to availability immediately. Do not build yet: No holds, payments or notifications yet. ### Phase 3 · Writing to your calendar, and nothing else Build: Create calendar events for bookings, identified by stored identifier. Never modify an event the tool did not create. Handle a sync token expiring and a full resync without duplicating bookings. Trap: Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. Done when: Booking events are identified by stored identifier rather than title. A real meeting cannot be modified or deleted, verified by a test that attempts it. A full resync after token expiry does not duplicate events. An event deleted from the calendar directly is detected and the booking is flagged rather than silently lost. Do not build yet: No holds, payments or reminders yet. ### Phase 4 · Holds, deposits and payment Build: Hold the slot for a short expiring period during checkout, counted against capacity. Take a deposit or full payment through hosted checkout and confirm on the webhook rather than the redirect. Handle a payment arriving after the hold expired. Trap: Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. Done when: A hold reserves the slot during checkout and expires automatically without user action. Payment confirms the booking on the webhook, verified by killing the browser immediately after paying. A payment arriving after the hold expired is handled explicitly rather than double-booking. A duplicate webhook creates one booking. Do not build yet: No reminders, cancellation or intake forms yet. ### Phase 5 · Reminders, cancellation and the client's experience Build: Confirmation and reminder messages through a queue with retries, in the client's timezone. A cancellation policy implemented as data with the refund computed once and shown before confirming. Intake forms attached to a booking. Trap: Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. Done when: Reminders show the appointment time in the client's timezone with the zone named. Reminder delivery retries on failure and is recorded. The refund shown before cancelling is the amount actually refunded, asserted by comparing quoted and executed figures. Intake responses are attached to the booking and available before the appointment. Do not build yet: Nothing after this. ### Out of scope (and why) - Multi-person scheduling requiring several attendees' availability, which is a substantially harder problem. - Staff rostering and resource allocation, which is an operations product rather than a booking one. - Two-way sync with external calendars for events the tool did not create, which is where duplication bugs live. - Tax and invoicing across jurisdictions, which is specialist work varying by territory. ### README must contain - That availability combines calendar, rules and bookings before any slot is offered - That a stale calendar sync narrows availability rather than offering slots optimistically - That the slot is reserved inside the booking transaction, because check-then-insert double-books - That the tool owns its calendar events by stored identifier and never touches anything else - That reminders show the appointment in the client's timezone, named ===== AGENTS.md ===== # Agent instructions · Acuity Scheduling indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Computed from calendar busy time, working hours and existing bookings, Slots held inside the booking transaction, not checked then written, Slots stored as instants, displayed in the booker's zone and yours, Hosted checkout with deposits, completed on webhook, Confirmations and reminders through a queue with retries. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ===== BUILD_PLAN.md ===== # Build plan · Acuity Scheduling A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability three sources agree on An offered slot is genuinely free. ### Steps 1. Combine three sources before offering Calendar, rules and bookings. Any one alone offers a slot that is taken. Files: `src/availability.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Fail closed on a stale sync Narrow availability rather than offering optimistically. Files: `src/availability.ts` ### Done when - [ ] Availability reflects all three sources with buffers - [ ] Recurrence with exceptions expands correctly - [ ] Declined and all-day events follow stated rules - [ ] A stale sync narrows rather than widens availability ### Watch out - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. ## Phase 2 · Booking a slot exactly once Two people booking the last slot simultaneously produce one booking. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when the page is busy. Files: `src/booking.ts` 2. Test with real concurrency Twenty parallel requests. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Offer alternatives on a loss The client is already committed to booking something. Files: `src/booking.ts` ### Done when - [ ] Twenty concurrent requests yield one booking - [ ] A lost race returns a clear message with alternatives - [ ] Capacity greater than one is respected exactly - [ ] Cancelling returns the slot immediately ### Watch out - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. ## Phase 3 · Writing to your calendar, and nothing else A booking appears in your calendar and no real meeting is ever touched. ### Steps 1. Own events by stored identifier A rename must not orphan a booking or endanger a meeting. Files: `src/calendar.ts` 2. Refuse to touch foreign events In the write path, tested by attempting it. Files: `tests/foreign-events.test.ts` 3. Handle resync idempotently Token expiry is routine and a duplicated week is very visible. Files: `src/sync.ts` ### Done when - [ ] Events are identified by stored id - [ ] Foreign events cannot be modified - [ ] A full resync does not duplicate - [ ] Externally deleted events flag their booking ### Watch out - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. ## Phase 4 · Holds, deposits and payment A client filling in details does not lose the slot, and paying is not lost either. ### Steps 1. Hold during checkout, expire on a timer Abandoned checkouts never come back to release the slot. Files: `src/holds.ts` 2. Confirm on the webhook Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Handle payment after expiry It happens, and double-booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] Holds expire without user action - [ ] Killing the browser after paying still books - [ ] Payment after expiry is handled explicitly - [ ] A duplicate webhook creates one booking ### Watch out - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ## Not in this build - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. ## After v1, if you want it - Waitlists for full slots, which turn a cancellation into a booking - Recurring appointments, which multiply every timezone and exception question here - Group classes with per-attendee intake, once single bookings are provably correct ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Client bookings and intake responses, which may include health information. DATABASE_URL=postgres://localhost:5432/bookings # Required · secret. Create and update only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required. Long enough to pay, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required · secret. Bookings confirm from webhooks, so an unverified endpoint books appointments. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
You are building a lean indie version of Acuity Scheduling. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Acuity Scheduling · indie build A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Availability | Computed from calendar busy time, working hours and existing bookings | three sources that must agree before a slot is offered | | Reservation | Slots held inside the booking transaction, not checked then written | check-then-insert double-books under exactly the conditions that matter | | Time | Slots stored as instants, displayed in the booker's zone and yours | the two people are frequently in different places | | Payments | Hosted checkout with deposits, completed on webhook | someone who paid and closed the tab has still paid | | Notifications | Confirmations and reminders through a queue with retries | a missed reminder is a missed appointment | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the sync workers and the notification queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Slot reservation correctness depends on real transactions and row contention, which is exactly what this is for. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar account with real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break availability, and none appear in an empty test calendar. - Get it: A calendar with a month of realistic meetings including a recurring series with a cancelled instance. - Verify: Availability correctly excludes the series but includes the cancelled instance - [ ] **A payment provider in test mode** · free to test - Why: Deposits, webhook confirmation and refunds behave differently from a single charge, and the redirect failure only appears against a real flow. - Get it: Sign up, use test keys, configure a webhook, and test paying then closing the browser immediately. - Verify: A payment with the browser killed still produces a booking - [ ] **Your cancellation policy as data** · free - Why: A policy written in prose gets implemented twice slightly differently and the client sees whichever path ran. - Get it: Express it as bands of hours before the appointment and refund percentages, and decide what happens when you cancel. - Verify: The policy is a table of bands with an explicit rule for your own cancellations ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Acuity Scheduling, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Acuity Scheduling The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that cannot double-book you, whatever your calendar is doing to replace Acuity Scheduling. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Availability: Computed from calendar busy time, working hours and existing bookings · three sources that must agree before a slot is offered - Reservation: Slots held inside the booking transaction, not checked then written · check-then-insert double-books under exactly the conditions that matter - Time: Slots stored as instants, displayed in the booker's zone and yours · the two people are frequently in different places - Payments: Hosted checkout with deposits, completed on webhook · someone who paid and closed the tab has still paid - Notifications: Confirmations and reminders through a queue with retries · a missed reminder is a missed appointment ### Data model (create this before Phase 1) - services(id, name, duration_minutes, buffer_before, buffer_after, capacity, price_minor, cancellation_policy_id) - availability_rules(id, service_id, day_of_week, start_local, end_local, timezone, valid_from, valid_to) - calendars(id, provider, account_ref, sync_token, last_synced_at, direction) · direction is read, write or both - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) - bookings(id, service_id, starts_at, ends_at, client_id, state, created_at, external_event_id, payment_id) - holds(id, service_id, starts_at, expires_at, session_id) · counted against capacity while alive - A booking and its calendar event are linked by a stored identifier, so the tool can recognise its own events and never touch anyone else's. ### Phase 1 · Availability three sources agree on Build: Compute availability from calendar busy time, your working rules and existing bookings together, applying buffers before and after. Handle recurring calendar events with exceptions, declined invitations and all-day events. Trap: Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. Done when: Availability reflects calendar busy time, working rules and existing bookings together with buffers applied. Recurring events with exceptions are expanded correctly. Declined and all-day events follow stated rules. A calendar that has not synced recently narrows availability rather than offering slots optimistically. Do not build yet: No booking, holds, payments or notifications. ### Phase 2 · Booking a slot exactly once Build: Reserve the slot inside the transaction that creates the booking so concurrent attempts serialise. Test with genuinely concurrent requests. Return a clear result when the slot went while the client was filling the form. Trap: Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. Done when: Twenty concurrent requests for one slot produce exactly one booking, asserted in a test with real concurrency. A booking that loses the race returns a clear message and offers alternatives. A service with capacity greater than one accepts exactly that many. Cancelling a booking returns its slot to availability immediately. Do not build yet: No holds, payments or notifications yet. ### Phase 3 · Writing to your calendar, and nothing else Build: Create calendar events for bookings, identified by stored identifier. Never modify an event the tool did not create. Handle a sync token expiring and a full resync without duplicating bookings. Trap: Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. Done when: Booking events are identified by stored identifier rather than title. A real meeting cannot be modified or deleted, verified by a test that attempts it. A full resync after token expiry does not duplicate events. An event deleted from the calendar directly is detected and the booking is flagged rather than silently lost. Do not build yet: No holds, payments or reminders yet. ### Phase 4 · Holds, deposits and payment Build: Hold the slot for a short expiring period during checkout, counted against capacity. Take a deposit or full payment through hosted checkout and confirm on the webhook rather than the redirect. Handle a payment arriving after the hold expired. Trap: Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. Done when: A hold reserves the slot during checkout and expires automatically without user action. Payment confirms the booking on the webhook, verified by killing the browser immediately after paying. A payment arriving after the hold expired is handled explicitly rather than double-booking. A duplicate webhook creates one booking. Do not build yet: No reminders, cancellation or intake forms yet. ### Phase 5 · Reminders, cancellation and the client's experience Build: Confirmation and reminder messages through a queue with retries, in the client's timezone. A cancellation policy implemented as data with the refund computed once and shown before confirming. Intake forms attached to a booking. Trap: Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. Done when: Reminders show the appointment time in the client's timezone with the zone named. Reminder delivery retries on failure and is recorded. The refund shown before cancelling is the amount actually refunded, asserted by comparing quoted and executed figures. Intake responses are attached to the booking and available before the appointment. Do not build yet: Nothing after this. ### Out of scope (and why) - Multi-person scheduling requiring several attendees' availability, which is a substantially harder problem. - Staff rostering and resource allocation, which is an operations product rather than a booking one. - Two-way sync with external calendars for events the tool did not create, which is where duplication bugs live. - Tax and invoicing across jurisdictions, which is specialist work varying by territory. ### README must contain - That availability combines calendar, rules and bookings before any slot is offered - That a stale calendar sync narrows availability rather than offering slots optimistically - That the slot is reserved inside the booking transaction, because check-then-insert double-books - That the tool owns its calendar events by stored identifier and never touches anything else - That reminders show the appointment in the client's timezone, named ===== AGENTS.md ===== # Agent instructions · Acuity Scheduling indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Computed from calendar busy time, working hours and existing bookings, Slots held inside the booking transaction, not checked then written, Slots stored as instants, displayed in the booker's zone and yours, Hosted checkout with deposits, completed on webhook, Confirmations and reminders through a queue with retries. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ===== BUILD_PLAN.md ===== # Build plan · Acuity Scheduling A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability three sources agree on An offered slot is genuinely free. ### Steps 1. Combine three sources before offering Calendar, rules and bookings. Any one alone offers a slot that is taken. Files: `src/availability.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Fail closed on a stale sync Narrow availability rather than offering optimistically. Files: `src/availability.ts` ### Done when - [ ] Availability reflects all three sources with buffers - [ ] Recurrence with exceptions expands correctly - [ ] Declined and all-day events follow stated rules - [ ] A stale sync narrows rather than widens availability ### Watch out - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. ## Phase 2 · Booking a slot exactly once Two people booking the last slot simultaneously produce one booking. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when the page is busy. Files: `src/booking.ts` 2. Test with real concurrency Twenty parallel requests. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Offer alternatives on a loss The client is already committed to booking something. Files: `src/booking.ts` ### Done when - [ ] Twenty concurrent requests yield one booking - [ ] A lost race returns a clear message with alternatives - [ ] Capacity greater than one is respected exactly - [ ] Cancelling returns the slot immediately ### Watch out - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. ## Phase 3 · Writing to your calendar, and nothing else A booking appears in your calendar and no real meeting is ever touched. ### Steps 1. Own events by stored identifier A rename must not orphan a booking or endanger a meeting. Files: `src/calendar.ts` 2. Refuse to touch foreign events In the write path, tested by attempting it. Files: `tests/foreign-events.test.ts` 3. Handle resync idempotently Token expiry is routine and a duplicated week is very visible. Files: `src/sync.ts` ### Done when - [ ] Events are identified by stored id - [ ] Foreign events cannot be modified - [ ] A full resync does not duplicate - [ ] Externally deleted events flag their booking ### Watch out - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. ## Phase 4 · Holds, deposits and payment A client filling in details does not lose the slot, and paying is not lost either. ### Steps 1. Hold during checkout, expire on a timer Abandoned checkouts never come back to release the slot. Files: `src/holds.ts` 2. Confirm on the webhook Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Handle payment after expiry It happens, and double-booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] Holds expire without user action - [ ] Killing the browser after paying still books - [ ] Payment after expiry is handled explicitly - [ ] A duplicate webhook creates one booking ### Watch out - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ## Not in this build - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. ## After v1, if you want it - Waitlists for full slots, which turn a cancellation into a booking - Recurring appointments, which multiply every timezone and exception question here - Group classes with per-attendee intake, once single bookings are provably correct ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Client bookings and intake responses, which may include health information. DATABASE_URL=postgres://localhost:5432/bookings # Required · secret. Create and update only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required. Long enough to pay, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required · secret. Bookings confirm from webhooks, so an unverified endpoint books appointments. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
You are building a production product version of Acuity Scheduling. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Acuity Scheduling · product brief ## Problem The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Acuity Scheduling earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that cannot sell the same slot twice, never offers a time you are already busy, and sends a reminder in the time the client actually reads. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - Twenty concurrent requests are confirmed to yield one booking - A stale sync is confirmed to narrow availability - A foreign calendar event is confirmed unmodifiable - Killing the browser after paying is confirmed to still book - Reminders are confirmed to show the client's timezone ===== BRIEF.md ===== # Build brief · Acuity Scheduling The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that cannot double-book you, whatever your calendar is doing to replace Acuity Scheduling. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Availability: Computed from calendar busy time, working hours and existing bookings · three sources that must agree before a slot is offered - Reservation: Slots held inside the booking transaction, not checked then written · check-then-insert double-books under exactly the conditions that matter - Time: Slots stored as instants, displayed in the booker's zone and yours · the two people are frequently in different places - Payments: Hosted checkout with deposits, completed on webhook · someone who paid and closed the tab has still paid - Notifications: Confirmations and reminders through a queue with retries · a missed reminder is a missed appointment ### Data model (create this before Phase 1) - services(id, name, duration_minutes, buffer_before, buffer_after, capacity, price_minor, cancellation_policy_id) - availability_rules(id, service_id, day_of_week, start_local, end_local, timezone, valid_from, valid_to) - calendars(id, provider, account_ref, sync_token, last_synced_at, direction) · direction is read, write or both - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) - bookings(id, service_id, starts_at, ends_at, client_id, state, created_at, external_event_id, payment_id) - holds(id, service_id, starts_at, expires_at, session_id) · counted against capacity while alive - A booking and its calendar event are linked by a stored identifier, so the tool can recognise its own events and never touch anyone else's. ### Phase 1 · Availability three sources agree on Build: Compute availability from calendar busy time, your working rules and existing bookings together, applying buffers before and after. Handle recurring calendar events with exceptions, declined invitations and all-day events. Trap: Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. Done when: Availability reflects calendar busy time, working rules and existing bookings together with buffers applied. Recurring events with exceptions are expanded correctly. Declined and all-day events follow stated rules. A calendar that has not synced recently narrows availability rather than offering slots optimistically. Do not build yet: No booking, holds, payments or notifications. ### Phase 2 · Booking a slot exactly once Build: Reserve the slot inside the transaction that creates the booking so concurrent attempts serialise. Test with genuinely concurrent requests. Return a clear result when the slot went while the client was filling the form. Trap: Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. Done when: Twenty concurrent requests for one slot produce exactly one booking, asserted in a test with real concurrency. A booking that loses the race returns a clear message and offers alternatives. A service with capacity greater than one accepts exactly that many. Cancelling a booking returns its slot to availability immediately. Do not build yet: No holds, payments or notifications yet. ### Phase 3 · Writing to your calendar, and nothing else Build: Create calendar events for bookings, identified by stored identifier. Never modify an event the tool did not create. Handle a sync token expiring and a full resync without duplicating bookings. Trap: Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. Done when: Booking events are identified by stored identifier rather than title. A real meeting cannot be modified or deleted, verified by a test that attempts it. A full resync after token expiry does not duplicate events. An event deleted from the calendar directly is detected and the booking is flagged rather than silently lost. Do not build yet: No holds, payments or reminders yet. ### Phase 4 · Holds, deposits and payment Build: Hold the slot for a short expiring period during checkout, counted against capacity. Take a deposit or full payment through hosted checkout and confirm on the webhook rather than the redirect. Handle a payment arriving after the hold expired. Trap: Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. Done when: A hold reserves the slot during checkout and expires automatically without user action. Payment confirms the booking on the webhook, verified by killing the browser immediately after paying. A payment arriving after the hold expired is handled explicitly rather than double-booking. A duplicate webhook creates one booking. Do not build yet: No reminders, cancellation or intake forms yet. ### Phase 5 · Reminders, cancellation and the client's experience Build: Confirmation and reminder messages through a queue with retries, in the client's timezone. A cancellation policy implemented as data with the refund computed once and shown before confirming. Intake forms attached to a booking. Trap: Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. Done when: Reminders show the appointment time in the client's timezone with the zone named. Reminder delivery retries on failure and is recorded. The refund shown before cancelling is the amount actually refunded, asserted by comparing quoted and executed figures. Intake responses are attached to the booking and available before the appointment. Do not build yet: Nothing after this. ### Out of scope (and why) - Multi-person scheduling requiring several attendees' availability, which is a substantially harder problem. - Staff rostering and resource allocation, which is an operations product rather than a booking one. - Two-way sync with external calendars for events the tool did not create, which is where duplication bugs live. - Tax and invoicing across jurisdictions, which is specialist work varying by territory. ### README must contain - That availability combines calendar, rules and bookings before any slot is offered - That a stale calendar sync narrows availability rather than offering slots optimistically - That the slot is reserved inside the booking transaction, because check-then-insert double-books - That the tool owns its calendar events by stored identifier and never touches anything else - That reminders show the appointment in the client's timezone, named ===== ARCHITECTURE.md ===== # Architecture · Acuity Scheduling ## Stack | Part | Choice | Why | | --- | --- | --- | | Availability | Computed from calendar busy time, working hours and existing bookings | three sources that must agree before a slot is offered | | Reservation | Slots held inside the booking transaction, not checked then written | check-then-insert double-books under exactly the conditions that matter | | Time | Slots stored as instants, displayed in the booker's zone and yours | the two people are frequently in different places | | Payments | Hosted checkout with deposits, completed on webhook | someone who paid and closed the tab has still paid | | Notifications | Confirmations and reminders through a queue with retries | a missed reminder is a missed appointment | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Combining calendar, rules and bookings. | Any calendar, provided a stale sync narrows rather than widens. | | Reservation | Slot capture inside the booking transaction. | Not swappable for check-then-insert, which double-books when it matters. | | Calendar writing | Events owned by stored identifier. | Any provider, provided foreign events are untouchable. | | Holds | Expiring reservations during checkout. | Any implementation, provided expiry is on a timer rather than an action. | | Notification | Retried delivery in the recipient's zone. | Any transport, provided times are shown in the client's zone. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Client bookings and intake responses, which may include health information. - `CALENDAR_CLIENT_ID` · required, secret · Create and update only the tool's own events. - `HOLD_MINUTES` · required · Long enough to pay, short enough not to block a popular slot. - `SYNC_STALE_MINUTES` · required · Beyond this availability narrows rather than being offered optimistically. - `STRIPE_WEBHOOK_SECRET` · required, secret · Bookings confirm from webhooks, so an unverified endpoint books appointments. - `TOUCH_FOREIGN_EVENTS` · required · Present as a constant. There is no other value. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Acuity Scheduling product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Computed from calendar busy time, working hours and existing bookings, Slots held inside the booking transaction, not checked then written, Slots stored as instants, displayed in the booker's zone and yours, Hosted checkout with deposits, completed on webhook, Confirmations and reminders through a queue with retries. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. - Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. ===== MILESTONES.md ===== # Delivery milestones · Acuity Scheduling Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability three sources agree on An offered slot is genuinely free. ### Steps 1. Combine three sources before offering Calendar, rules and bookings. Any one alone offers a slot that is taken. Files: `src/availability.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Fail closed on a stale sync Narrow availability rather than offering optimistically. Files: `src/availability.ts` ### Done when - [ ] Availability reflects all three sources with buffers - [ ] Recurrence with exceptions expands correctly - [ ] Declined and all-day events follow stated rules - [ ] A stale sync narrows rather than widens availability ### Watch out - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. ## M2 · Booking a slot exactly once Two people booking the last slot simultaneously produce one booking. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when the page is busy. Files: `src/booking.ts` 2. Test with real concurrency Twenty parallel requests. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Offer alternatives on a loss The client is already committed to booking something. Files: `src/booking.ts` ### Done when - [ ] Twenty concurrent requests yield one booking - [ ] A lost race returns a clear message with alternatives - [ ] Capacity greater than one is respected exactly - [ ] Cancelling returns the slot immediately ### Watch out - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. ## M3 · Writing to your calendar, and nothing else A booking appears in your calendar and no real meeting is ever touched. ### Steps 1. Own events by stored identifier A rename must not orphan a booking or endanger a meeting. Files: `src/calendar.ts` 2. Refuse to touch foreign events In the write path, tested by attempting it. Files: `tests/foreign-events.test.ts` 3. Handle resync idempotently Token expiry is routine and a duplicated week is very visible. Files: `src/sync.ts` ### Done when - [ ] Events are identified by stored id - [ ] Foreign events cannot be modified - [ ] A full resync does not duplicate - [ ] Externally deleted events flag their booking ### Watch out - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. ## M4 · Holds, deposits and payment A client filling in details does not lose the slot, and paying is not lost either. ### Steps 1. Hold during checkout, expire on a timer Abandoned checkouts never come back to release the slot. Files: `src/holds.ts` 2. Confirm on the webhook Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Handle payment after expiry It happens, and double-booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] Holds expire without user action - [ ] Killing the browser after paying still books - [ ] Payment after expiry is handled explicitly - [ ] A duplicate webhook creates one booking ### Watch out - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ## M5 · Reminders, cancellation and the client's experience (production only) Both parties know what is happening and cancelling works as promised. ### Steps 1. Send times in the recipient's zone Labelled. It is the message that must not be misread. Files: `src/notify.ts` 2. Compute the refund once The quoted figure is the refunded figure, tested. Files: `src/refund.ts`, `src/refund.test.ts` 3. Attach intake to the booking So the information is there when the appointment starts. Files: `src/intake.ts` ### Done when - [ ] Reminders show the client's zone, named - [ ] Delivery retries and is recorded - [ ] The quoted refund equals the executed refund - [ ] Intake responses are attached and available ### Watch out - Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. ===== OPERATIONS.md ===== # Operations · Acuity Scheduling ## Backup Bookings are commitments to people who will turn up. Nightly backups with a tested restore, and note that intake responses may include health information, which raises the sensitivity of the whole table. ## Restore Restore and confirm bookings and their calendar event identifiers agree, since a restore that loses the links will duplicate every event on the next sync. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar sync age and hold expiry rate. A stale sync is the leading indicator of a double booking, and holds expiring without conversion usually means the checkout is failing rather than clients changing their minds. ## Incident checklist A double booking is the failure that reaches two clients at once, and it means capacity was taken outside the transaction or availability was computed against a stale calendar. The concurrency test covers the first and the staleness rule the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Twenty concurrent requests are confirmed to yield one booking - [ ] A stale sync is confirmed to narrow availability - [ ] A foreign calendar event is confirmed unmodifiable - [ ] Killing the browser after paying is confirmed to still book - [ ] Reminders are confirmed to show the client's timezone ## Launch constraint Do not market omitted Acuity Scheduling capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Client bookings and intake responses, which may include health information. DATABASE_URL=postgres://localhost:5432/bookings # Required · secret. Create and update only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required. Long enough to pay, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required · secret. Bookings confirm from webhooks, so an unverified endpoint books appointments. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
# Acuity Scheduling · indie build A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Availability | Computed from calendar busy time, working hours and existing bookings | three sources that must agree before a slot is offered | | Reservation | Slots held inside the booking transaction, not checked then written | check-then-insert double-books under exactly the conditions that matter | | Time | Slots stored as instants, displayed in the booker's zone and yours | the two people are frequently in different places | | Payments | Hosted checkout with deposits, completed on webhook | someone who paid and closed the tab has still paid | | Notifications | Confirmations and reminders through a queue with retries | a missed reminder is a missed appointment | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the sync workers and the notification queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Slot reservation correctness depends on real transactions and row contention, which is exactly what this is for. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar account with real meetings** · free - Why: Recurrence with exceptions, declined invitations and all-day events are the cases that break availability, and none appear in an empty test calendar. - Get it: A calendar with a month of realistic meetings including a recurring series with a cancelled instance. - Verify: Availability correctly excludes the series but includes the cancelled instance - [ ] **A payment provider in test mode** · free to test - Why: Deposits, webhook confirmation and refunds behave differently from a single charge, and the redirect failure only appears against a real flow. - Get it: Sign up, use test keys, configure a webhook, and test paying then closing the browser immediately. - Verify: A payment with the browser killed still produces a booking - [ ] **Your cancellation policy as data** · free - Why: A policy written in prose gets implemented twice slightly differently and the client sees whichever path ran. - Get it: Express it as bands of hours before the appointment and refund percentages, and decide what happens when you cancel. - Verify: The policy is a table of bands with an explicit rule for your own cancellations ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Acuity Scheduling, and the README should say so rather than pretend.
# Build brief · Acuity Scheduling The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that cannot double-book you, whatever your calendar is doing to replace Acuity Scheduling. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Availability: Computed from calendar busy time, working hours and existing bookings · three sources that must agree before a slot is offered - Reservation: Slots held inside the booking transaction, not checked then written · check-then-insert double-books under exactly the conditions that matter - Time: Slots stored as instants, displayed in the booker's zone and yours · the two people are frequently in different places - Payments: Hosted checkout with deposits, completed on webhook · someone who paid and closed the tab has still paid - Notifications: Confirmations and reminders through a queue with retries · a missed reminder is a missed appointment ### Data model (create this before Phase 1) - services(id, name, duration_minutes, buffer_before, buffer_after, capacity, price_minor, cancellation_policy_id) - availability_rules(id, service_id, day_of_week, start_local, end_local, timezone, valid_from, valid_to) - calendars(id, provider, account_ref, sync_token, last_synced_at, direction) · direction is read, write or both - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) - bookings(id, service_id, starts_at, ends_at, client_id, state, created_at, external_event_id, payment_id) - holds(id, service_id, starts_at, expires_at, session_id) · counted against capacity while alive - A booking and its calendar event are linked by a stored identifier, so the tool can recognise its own events and never touch anyone else's. ### Phase 1 · Availability three sources agree on Build: Compute availability from calendar busy time, your working rules and existing bookings together, applying buffers before and after. Handle recurring calendar events with exceptions, declined invitations and all-day events. Trap: Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. Done when: Availability reflects calendar busy time, working rules and existing bookings together with buffers applied. Recurring events with exceptions are expanded correctly. Declined and all-day events follow stated rules. A calendar that has not synced recently narrows availability rather than offering slots optimistically. Do not build yet: No booking, holds, payments or notifications. ### Phase 2 · Booking a slot exactly once Build: Reserve the slot inside the transaction that creates the booking so concurrent attempts serialise. Test with genuinely concurrent requests. Return a clear result when the slot went while the client was filling the form. Trap: Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. Done when: Twenty concurrent requests for one slot produce exactly one booking, asserted in a test with real concurrency. A booking that loses the race returns a clear message and offers alternatives. A service with capacity greater than one accepts exactly that many. Cancelling a booking returns its slot to availability immediately. Do not build yet: No holds, payments or notifications yet. ### Phase 3 · Writing to your calendar, and nothing else Build: Create calendar events for bookings, identified by stored identifier. Never modify an event the tool did not create. Handle a sync token expiring and a full resync without duplicating bookings. Trap: Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. Done when: Booking events are identified by stored identifier rather than title. A real meeting cannot be modified or deleted, verified by a test that attempts it. A full resync after token expiry does not duplicate events. An event deleted from the calendar directly is detected and the booking is flagged rather than silently lost. Do not build yet: No holds, payments or reminders yet. ### Phase 4 · Holds, deposits and payment Build: Hold the slot for a short expiring period during checkout, counted against capacity. Take a deposit or full payment through hosted checkout and confirm on the webhook rather than the redirect. Handle a payment arriving after the hold expired. Trap: Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. Done when: A hold reserves the slot during checkout and expires automatically without user action. Payment confirms the booking on the webhook, verified by killing the browser immediately after paying. A payment arriving after the hold expired is handled explicitly rather than double-booking. A duplicate webhook creates one booking. Do not build yet: No reminders, cancellation or intake forms yet. ### Phase 5 · Reminders, cancellation and the client's experience Build: Confirmation and reminder messages through a queue with retries, in the client's timezone. A cancellation policy implemented as data with the refund computed once and shown before confirming. Intake forms attached to a booking. Trap: Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. Done when: Reminders show the appointment time in the client's timezone with the zone named. Reminder delivery retries on failure and is recorded. The refund shown before cancelling is the amount actually refunded, asserted by comparing quoted and executed figures. Intake responses are attached to the booking and available before the appointment. Do not build yet: Nothing after this. ### Out of scope (and why) - Multi-person scheduling requiring several attendees' availability, which is a substantially harder problem. - Staff rostering and resource allocation, which is an operations product rather than a booking one. - Two-way sync with external calendars for events the tool did not create, which is where duplication bugs live. - Tax and invoicing across jurisdictions, which is specialist work varying by territory. ### README must contain - That availability combines calendar, rules and bookings before any slot is offered - That a stale calendar sync narrows availability rather than offering slots optimistically - That the slot is reserved inside the booking transaction, because check-then-insert double-books - That the tool owns its calendar events by stored identifier and never touches anything else - That reminders show the appointment in the client's timezone, named
# Agent instructions · Acuity Scheduling indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Computed from calendar busy time, working hours and existing bookings, Slots held inside the booking transaction, not checked then written, Slots stored as instants, displayed in the booker's zone and yours, Hosted checkout with deposits, completed on webhook, Confirmations and reminders through a queue with retries. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience.
# Build plan · Acuity Scheduling A client booking system where the correctness problem is a race between your calendar and your booking page: availability computed a second ago may already be gone, and the person who loses that race is a client standing in front of a closed door. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability three sources agree on An offered slot is genuinely free. ### Steps 1. Combine three sources before offering Calendar, rules and bookings. Any one alone offers a slot that is taken. Files: `src/availability.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Fail closed on a stale sync Narrow availability rather than offering optimistically. Files: `src/availability.ts` ### Done when - [ ] Availability reflects all three sources with buffers - [ ] Recurrence with exceptions expands correctly - [ ] Declined and all-day events follow stated rules - [ ] A stale sync narrows rather than widens availability ### Watch out - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. ## Phase 2 · Booking a slot exactly once Two people booking the last slot simultaneously produce one booking. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when the page is busy. Files: `src/booking.ts` 2. Test with real concurrency Twenty parallel requests. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Offer alternatives on a loss The client is already committed to booking something. Files: `src/booking.ts` ### Done when - [ ] Twenty concurrent requests yield one booking - [ ] A lost race returns a clear message with alternatives - [ ] Capacity greater than one is respected exactly - [ ] Cancelling returns the slot immediately ### Watch out - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. ## Phase 3 · Writing to your calendar, and nothing else A booking appears in your calendar and no real meeting is ever touched. ### Steps 1. Own events by stored identifier A rename must not orphan a booking or endanger a meeting. Files: `src/calendar.ts` 2. Refuse to touch foreign events In the write path, tested by attempting it. Files: `tests/foreign-events.test.ts` 3. Handle resync idempotently Token expiry is routine and a duplicated week is very visible. Files: `src/sync.ts` ### Done when - [ ] Events are identified by stored id - [ ] Foreign events cannot be modified - [ ] A full resync does not duplicate - [ ] Externally deleted events flag their booking ### Watch out - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. ## Phase 4 · Holds, deposits and payment A client filling in details does not lose the slot, and paying is not lost either. ### Steps 1. Hold during checkout, expire on a timer Abandoned checkouts never come back to release the slot. Files: `src/holds.ts` 2. Confirm on the webhook Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Handle payment after expiry It happens, and double-booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] Holds expire without user action - [ ] Killing the browser after paying still books - [ ] Payment after expiry is handled explicitly - [ ] A duplicate webhook creates one booking ### Watch out - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ## Not in this build - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. ## After v1, if you want it - Waitlists for full slots, which turn a cancellation into a booking - Recurring appointments, which multiply every timezone and exception question here - Group classes with per-attendee intake, once single bookings are provably correct
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Client bookings and intake responses, which may include health information. DATABASE_URL=postgres://localhost:5432/bookings # Required · secret. Create and update only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required. Long enough to pay, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required · secret. Bookings confirm from webhooks, so an unverified endpoint books appointments. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
# Acuity Scheduling · product brief ## Problem The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Acuity Scheduling earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that cannot sell the same slot twice, never offers a time you are already busy, and sends a reminder in the time the client actually reads. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Multi-person availability scheduling. - Staff rostering and resource allocation. - Two-way sync for foreign events. - Cross-jurisdiction tax and invoicing. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - Twenty concurrent requests are confirmed to yield one booking - A stale sync is confirmed to narrow availability - A foreign calendar event is confirmed unmodifiable - Killing the browser after paying is confirmed to still book - Reminders are confirmed to show the client's timezone
# Build brief · Acuity Scheduling The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that cannot double-book you, whatever your calendar is doing to replace Acuity Scheduling. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Availability: Computed from calendar busy time, working hours and existing bookings · three sources that must agree before a slot is offered - Reservation: Slots held inside the booking transaction, not checked then written · check-then-insert double-books under exactly the conditions that matter - Time: Slots stored as instants, displayed in the booker's zone and yours · the two people are frequently in different places - Payments: Hosted checkout with deposits, completed on webhook · someone who paid and closed the tab has still paid - Notifications: Confirmations and reminders through a queue with retries · a missed reminder is a missed appointment ### Data model (create this before Phase 1) - services(id, name, duration_minutes, buffer_before, buffer_after, capacity, price_minor, cancellation_policy_id) - availability_rules(id, service_id, day_of_week, start_local, end_local, timezone, valid_from, valid_to) - calendars(id, provider, account_ref, sync_token, last_synced_at, direction) · direction is read, write or both - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) - bookings(id, service_id, starts_at, ends_at, client_id, state, created_at, external_event_id, payment_id) - holds(id, service_id, starts_at, expires_at, session_id) · counted against capacity while alive - A booking and its calendar event are linked by a stored identifier, so the tool can recognise its own events and never touch anyone else's. ### Phase 1 · Availability three sources agree on Build: Compute availability from calendar busy time, your working rules and existing bookings together, applying buffers before and after. Handle recurring calendar events with exceptions, declined invitations and all-day events. Trap: Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. Done when: Availability reflects calendar busy time, working rules and existing bookings together with buffers applied. Recurring events with exceptions are expanded correctly. Declined and all-day events follow stated rules. A calendar that has not synced recently narrows availability rather than offering slots optimistically. Do not build yet: No booking, holds, payments or notifications. ### Phase 2 · Booking a slot exactly once Build: Reserve the slot inside the transaction that creates the booking so concurrent attempts serialise. Test with genuinely concurrent requests. Return a clear result when the slot went while the client was filling the form. Trap: Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. Done when: Twenty concurrent requests for one slot produce exactly one booking, asserted in a test with real concurrency. A booking that loses the race returns a clear message and offers alternatives. A service with capacity greater than one accepts exactly that many. Cancelling a booking returns its slot to availability immediately. Do not build yet: No holds, payments or notifications yet. ### Phase 3 · Writing to your calendar, and nothing else Build: Create calendar events for bookings, identified by stored identifier. Never modify an event the tool did not create. Handle a sync token expiring and a full resync without duplicating bookings. Trap: Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. Done when: Booking events are identified by stored identifier rather than title. A real meeting cannot be modified or deleted, verified by a test that attempts it. A full resync after token expiry does not duplicate events. An event deleted from the calendar directly is detected and the booking is flagged rather than silently lost. Do not build yet: No holds, payments or reminders yet. ### Phase 4 · Holds, deposits and payment Build: Hold the slot for a short expiring period during checkout, counted against capacity. Take a deposit or full payment through hosted checkout and confirm on the webhook rather than the redirect. Handle a payment arriving after the hold expired. Trap: Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. Done when: A hold reserves the slot during checkout and expires automatically without user action. Payment confirms the booking on the webhook, verified by killing the browser immediately after paying. A payment arriving after the hold expired is handled explicitly rather than double-booking. A duplicate webhook creates one booking. Do not build yet: No reminders, cancellation or intake forms yet. ### Phase 5 · Reminders, cancellation and the client's experience Build: Confirmation and reminder messages through a queue with retries, in the client's timezone. A cancellation policy implemented as data with the refund computed once and shown before confirming. Intake forms attached to a booking. Trap: Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary. Done when: Reminders show the appointment time in the client's timezone with the zone named. Reminder delivery retries on failure and is recorded. The refund shown before cancelling is the amount actually refunded, asserted by comparing quoted and executed figures. Intake responses are attached to the booking and available before the appointment. Do not build yet: Nothing after this. ### Out of scope (and why) - Multi-person scheduling requiring several attendees' availability, which is a substantially harder problem. - Staff rostering and resource allocation, which is an operations product rather than a booking one. - Two-way sync with external calendars for events the tool did not create, which is where duplication bugs live. - Tax and invoicing across jurisdictions, which is specialist work varying by territory. ### README must contain - That availability combines calendar, rules and bookings before any slot is offered - That a stale calendar sync narrows availability rather than offering slots optimistically - That the slot is reserved inside the booking transaction, because check-then-insert double-books - That the tool owns its calendar events by stored identifier and never touches anything else - That reminders show the appointment in the client's timezone, named
# Architecture · Acuity Scheduling ## Stack | Part | Choice | Why | | --- | --- | --- | | Availability | Computed from calendar busy time, working hours and existing bookings | three sources that must agree before a slot is offered | | Reservation | Slots held inside the booking transaction, not checked then written | check-then-insert double-books under exactly the conditions that matter | | Time | Slots stored as instants, displayed in the booker's zone and yours | the two people are frequently in different places | | Payments | Hosted checkout with deposits, completed on webhook | someone who paid and closed the tab has still paid | | Notifications | Confirmations and reminders through a queue with retries | a missed reminder is a missed appointment | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Combining calendar, rules and bookings. | Any calendar, provided a stale sync narrows rather than widens. | | Reservation | Slot capture inside the booking transaction. | Not swappable for check-then-insert, which double-books when it matters. | | Calendar writing | Events owned by stored identifier. | Any provider, provided foreign events are untouchable. | | Holds | Expiring reservations during checkout. | Any implementation, provided expiry is on a timer rather than an action. | | Notification | Retried delivery in the recipient's zone. | Any transport, provided times are shown in the client's zone. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Client bookings and intake responses, which may include health information. - `CALENDAR_CLIENT_ID` · required, secret · Create and update only the tool's own events. - `HOLD_MINUTES` · required · Long enough to pay, short enough not to block a popular slot. - `SYNC_STALE_MINUTES` · required · Beyond this availability narrows rather than being offered optimistically. - `STRIPE_WEBHOOK_SECRET` · required, secret · Bookings confirm from webhooks, so an unverified endpoint books appointments. - `TOUCH_FOREIGN_EVENTS` · required · Present as a constant. There is no other value. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Acuity Scheduling product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Computed from calendar busy time, working hours and existing bookings, Slots held inside the booking transaction, not checked then written, Slots stored as instants, displayed in the booker's zone and yours, Hosted checkout with deposits, completed on webhook, Confirmations and reminders through a queue with retries. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. - Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary.
# Delivery milestones · Acuity Scheduling Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability three sources agree on An offered slot is genuinely free. ### Steps 1. Combine three sources before offering Calendar, rules and bookings. Any one alone offers a slot that is taken. Files: `src/availability.ts` 2. Expand recurrence with exceptions The series minus its cancellations, not the series. Files: `src/recurrence.ts`, `src/recurrence.test.ts` 3. Fail closed on a stale sync Narrow availability rather than offering optimistically. Files: `src/availability.ts` ### Done when - [ ] Availability reflects all three sources with buffers - [ ] Recurrence with exceptions expands correctly - [ ] Declined and all-day events follow stated rules - [ ] A stale sync narrows rather than widens availability ### Watch out - Computing availability from your rules alone and checking the calendar later means a slot is offered during a meeting, and the client books it. Combine all three sources before offering anything, and treat a stale calendar sync as a reason to narrow availability rather than to widen it. ## M2 · Booking a slot exactly once Two people booking the last slot simultaneously produce one booking. ### Steps 1. Reserve inside the booking transaction Check-then-insert double-books when the page is busy. Files: `src/booking.ts` 2. Test with real concurrency Twenty parallel requests. Sequential tests prove nothing here. Files: `tests/concurrency.test.ts` 3. Offer alternatives on a loss The client is already committed to booking something. Files: `src/booking.ts` ### Done when - [ ] Twenty concurrent requests yield one booking - [ ] A lost race returns a clear message with alternatives - [ ] Capacity greater than one is respected exactly - [ ] Cancelling returns the slot immediately ### Watch out - Checking availability and then inserting a booking is a race with a window of a few hundred milliseconds, and it double-books precisely when a page is popular. Take the slot within the booking transaction, and let the database serialise the contention. ## M3 · Writing to your calendar, and nothing else A booking appears in your calendar and no real meeting is ever touched. ### Steps 1. Own events by stored identifier A rename must not orphan a booking or endanger a meeting. Files: `src/calendar.ts` 2. Refuse to touch foreign events In the write path, tested by attempting it. Files: `tests/foreign-events.test.ts` 3. Handle resync idempotently Token expiry is routine and a duplicated week is very visible. Files: `src/sync.ts` ### Done when - [ ] Events are identified by stored id - [ ] Foreign events cannot be modified - [ ] A full resync does not duplicate - [ ] Externally deleted events flag their booking ### Watch out - Identifying your own events by title means a renamed booking becomes invisible to the tool, which then creates a duplicate, or a coincidence of naming lets it modify a real meeting. Store the created event identifier and treat everything else as untouchable. ## M4 · Holds, deposits and payment A client filling in details does not lose the slot, and paying is not lost either. ### Steps 1. Hold during checkout, expire on a timer Abandoned checkouts never come back to release the slot. Files: `src/holds.ts` 2. Confirm on the webhook Test by killing the browser after paying. Files: `src/webhooks.ts` 3. Handle payment after expiry It happens, and double-booking is not the answer. Files: `src/holds.ts` ### Done when - [ ] Holds expire without user action - [ ] Killing the browser after paying still books - [ ] Payment after expiry is handled explicitly - [ ] A duplicate webhook creates one booking ### Watch out - Confirming a booking on the browser redirect loses the booking whenever a client pays and closes the tab, which happens constantly on a phone, and you have their money and no appointment. The webhook is the source of truth and the redirect is a convenience. ## M5 · Reminders, cancellation and the client's experience (production only) Both parties know what is happening and cancelling works as promised. ### Steps 1. Send times in the recipient's zone Labelled. It is the message that must not be misread. Files: `src/notify.ts` 2. Compute the refund once The quoted figure is the refunded figure, tested. Files: `src/refund.ts`, `src/refund.test.ts` 3. Attach intake to the booking So the information is there when the appointment starts. Files: `src/intake.ts` ### Done when - [ ] Reminders show the client's zone, named - [ ] Delivery retries and is recorded - [ ] The quoted refund equals the executed refund - [ ] Intake responses are attached and available ### Watch out - Sending a reminder with the time in your timezone rather than the client's is how someone arrives an hour late having read it correctly, and it happens on every cross-border booking. Send times in the recipient's zone, labelled, and include yours as a secondary.
# Operations · Acuity Scheduling ## Backup Bookings are commitments to people who will turn up. Nightly backups with a tested restore, and note that intake responses may include health information, which raises the sensitivity of the whole table. ## Restore Restore and confirm bookings and their calendar event identifiers agree, since a restore that loses the links will duplicate every event on the next sync. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar sync age and hold expiry rate. A stale sync is the leading indicator of a double booking, and holds expiring without conversion usually means the checkout is failing rather than clients changing their minds. ## Incident checklist A double booking is the failure that reaches two clients at once, and it means capacity was taken outside the transaction or availability was computed against a stale calendar. The concurrency test covers the first and the staleness rule the second. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Twenty concurrent requests are confirmed to yield one booking - [ ] A stale sync is confirmed to narrow availability - [ ] A foreign calendar event is confirmed unmodifiable - [ ] Killing the browser after paying is confirmed to still book - [ ] Reminders are confirmed to show the client's timezone ## Launch constraint Do not market omitted Acuity Scheduling capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Client bookings and intake responses, which may include health information. DATABASE_URL=postgres://localhost:5432/bookings # Required · secret. Create and update only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required. Long enough to pay, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required · secret. Bookings confirm from webhooks, so an unverified endpoint books appointments. STRIPE_WEBHOOK_SECRET=whsec_... # Required. Present as a constant. There is no other value. TOUCH_FOREIGN_EVENTS=never
$ choose a build depth, inspect the files, then open the complete pack in your agent
Acuity Scheduling: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
xcalendar-provider edge cases and timezone correctness
xmulti-calendar conflict handling
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Acuity Scheduling →· no votes, no pay-to-list · just what's real
Acuity Scheduling pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $20 | $16 | 1 calendar, unlimited services and appointments, intake forms and email reminders |
| standard | $34 | $27 | Up to 6 calendars; text reminders; packages, memberships and gift certificates |
| premium | $61 | $49 | Up to 36 calendars; HIPAA BAA, removed branding, multiple time zones, API and custom CSS |
| enterprise | custom | — | Unlimited calendars, unlimited scheduling instances, additional APIs and VIP account management |
free tierno free tier
billingmonthly + annual (about 20% off); 7-day trial, no credit card
hidden costsTaxes and ordinary payment-processor fees are extra. Calendar limits are hard plan differentiators; there are no setup, cancellation or support fees on self-serve plans.
verified 2026-08-14 · source ↗
Vibecode Acuity Scheduling
Kinda. The core of Acuity Scheduling is buildable in a weekend with the prompt on this page, but there are real gaps: race-free booking under load, team routing and round robin. Read the honest list above before committing.
How much does Acuity Scheduling cost?
Acuity Scheduling costs about $20/month (Emerging, checked 2026-07-31), which is $240 per year.
What do I lose by replacing Acuity Scheduling?
Honestly: race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases; calendar-provider edge cases and timezone correctness; multi-calendar conflict handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Acuity Scheduling?
Yes: Appointy Free (One staffer gets reminders, classes and Square checkout for 100 bookings a month; growth wakes the paywall.) Setmore Free (Four staff, payments, classes and 200 monthly bookings; SMS, recurring appointments and two-way sync cost extra.) Square Appointments Free (A serious free solo booking stack where Square operates; processing fees remain and the feature mix varies by country.) All 4 curated free alternatives are at vibecodeit.com/acuity-scheduling/alternatives. The prompt is for when you want it exactly your way.