Vibecode SimplyBook.me
track this build4 phases, 12 steps, beginner friendly0%The visible service booking loop is buildable, but a credible replacement needs more than the first screen. SimplyBook.me 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 SimplyBook.me. 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 ===== # SimplyBook.me · indie build A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and one background worker for sync | renewal and reconciliation cannot live in a request | | Storage | SQLite with a unique constraint that makes an overlapping hold impossible | the double-booking bug is a constraint you either wrote or did not | | Calendar | One provider, free and busy queries plus incremental sync | titles never enter the database, so they cannot leak | | Time | Instants stored in UTC, working hours stored as local rules plus a zone | a working day is local; a booking is an instant | | Email | A transactional provider, messages sent from an outbox | a failed confirmation must not undo a confirmed booking | ## 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 application and the sync worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with API credentials** · free at personal volume - Why: The whole product is a correct mirror of one calendar, and the credentials decide which scopes you can ask for. - Get it: Create a project with the provider, enable the calendar API, and create OAuth credentials with the narrowest scopes that allow reading free and busy information and creating events. - Verify: A free and busy query returns intervals and an event you create appears in the calendar - [ ] **A public HTTPS callback with a valid certificate** · a few pounds a month - Why: The OAuth redirect and the push notification receiver both need a reachable address with a certificate the provider trusts; self-signed will not do. - Get it: A small host with a domain and a certificate, or a tunnel that terminates TLS properly for development. - Verify: The provider delivers a test notification to the address - [ ] **A transactional email provider key** · free tier is ample - Why: Confirmations and reminders go out through it, and a booking must survive its outage. - Get it: Create an account, verify a sending domain and issue a sending-only key. - Verify: A confirmation arrives and the provider reports it accepted - [ ] **Your buffers, notice, horizon, hold expiry and clock change rule** · free - Why: Each is asserted by a test, and deciding them after watching a failure means picking whatever the code already does. - Get it: Write down buffer before and after, minimum notice, how far ahead people may book, how long a hold lives, and what happens to a slot in the missing hour. - Verify: All five are in the configuration and named in tests ## 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: - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases If one of those is essential to you, that is the reason to keep paying for SimplyBook.me, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SimplyBook.me 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 reads one calendar, hands out slots, and cannot double-book you to replace SimplyBook.me. 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. ### Rule zero Two rules, and the first is about other people's privacy. Availability is computed from busy intervals only: a start, an end, and nothing else. Never read an event's title, attendees or description into your database and never render one, because a public booking page that shows 'busy: Oncology appointment' is a disclosure rather than a feature. Ask the calendar for free and busy information, not for events, wherever the provider offers that distinction. Second, a slot is held in your own database, inside a transaction, before the calendar is touched. Two people clicking the same slot in the same second is the normal case ten minutes after you send a newsletter, not an edge case, and a check followed by a write is not a lock. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and one background worker for sync · renewal and reconciliation cannot live in a request - Storage: SQLite with a unique constraint that makes an overlapping hold impossible · the double-booking bug is a constraint you either wrote or did not - Calendar: One provider, free and busy queries plus incremental sync · titles never enter the database, so they cannot leak - Time: Instants stored in UTC, working hours stored as local rules plus a zone · a working day is local; a booking is an instant - Email: A transactional provider, messages sent from an outbox · a failed confirmation must not undo a confirmed booking ### Data model (create this before Phase 1) - calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late. ### Phase 1 · Availability without reading anyone's diary Build: Query the calendar for busy intervals over the horizon and store start and end only. Subtract them, plus buffers and minimum notice, from working hours expanded from local rules using the zone's rules for each date. Publish the remaining slots as instants and render them in the viewer's zone. Trap: Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. Done when: No event title, description or attendee is ever written to the database, asserted by a test over the schema and the sync code. Slots on both clock change days resolve by a stated rule, tested with both dates. A busy interval overlapping a working hour removes exactly the overlapping slots, tested with partial overlaps at both ends. Slots render in the viewer's zone and the underlying instant is unchanged by that rendering. Do not build yet: No booking, holds, calendar writes or email. ### Phase 2 · Two people, one slot, one winner Build: Booking inserts a hold row with an expiry inside a transaction, guarded by a constraint that refuses an overlapping hold for the same service. Only after the hold commits do you write the calendar event and promote the hold to a booking. Expire abandoned holds on a timer. Trap: Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. Done when: Two concurrent requests for the same slot produce one booking and one refusal, tested by firing both from the same test. An abandoned hold expires and the slot reappears, tested. A calendar write that fails leaves no booking and releases the hold, tested by making the write fail. No code path books without holding first, verified by review. Do not build yet: No incremental sync, reminders or cancellation yet. ### Phase 3 · Keeping the mirror honest Build: Sync incrementally with the provider's sync token, and subscribe to push notifications so a change wakes the worker rather than waiting for a poll. Handle an invalidated token by discarding local busy blocks and doing a full resync. Renew the notification channel before it expires and reconcile on every start. Trap: Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. Trap: Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. Done when: A 410 response triggers a full resync and the busy blocks afterwards match the calendar, tested by invalidating a token deliberately. A notification channel is renewed before its stored expiry and the new channel identifier differs from the old one. An event created in the calendar app is reflected in busy blocks within a stated interval, tested end to end. Sync failure marks the calendar stale and the booking page refuses new bookings rather than offering slots from an out-of-date mirror. Do not build yet: No reminders, cancellation or export yet. ### Phase 4 · Times people can read, sent once Build: Capture the invitee's zone at booking and render every time in it with the zone named. Queue the confirmation and the reminder in an outbox with a send-after instant, delivered by the worker with backoff. A booking is confirmed whether or not the email succeeds. Trap: Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. Done when: Every time in every message carries a named zone and matches the stored instant, tested by booking from a machine set to a different zone. A reminder scheduled across a clock change fires at the correct instant, tested with both transition dates. An email provider outage leaves the booking confirmed and the message queued, tested. A queued message is never sent twice, asserted with a delivery identifier. Do not build yet: No cancellation, rescheduling or data export yet. ### Phase 5 · Cancelling, leaving and taking your data Build: Issue an unguessable cancellation link per booking that allows cancelling or rescheduling and nothing else. On the first authorisation failure, pause the calendar, stop offering slots and alert once. Export bookings as CSV and iCalendar. Delete an invitee's details on request, including from the calendar event, and keep only the interval. Trap: A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. Done when: A cancellation token is random, stored hashed, scoped to one booking, and an adjacent identifier reveals nothing, tested. Revoking the calendar grant pauses the calendar within one worker cycle, stops the booking page offering slots and alerts once, tested by revoking it. The export re-imports into an empty instance and the iCalendar file opens in a calendar application. A deletion request removes the invitee's name and address from the database and the calendar event, leaving the interval, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Multiple staff with round robin routing, which turns one availability calculation into a fairness algorithm and a second class of double booking. - Taking payment at booking, which adds refunds, disputes and a no-show policy to what is currently a calendar problem. - A second calendar provider until the first one has survived a token invalidation and a channel expiry in production. - A public site builder and a service marketplace, which are the add-on business rather than the booking loop. ### README must contain - That availability comes from busy intervals only, and that no event title or attendee is ever stored - That a slot is held under a database constraint before the calendar is written, because a check followed by a write is not a lock - That an invalidated sync token is a normal event with a defined full resync, and that push channels must be renewed before they expire - That a stale calendar mirror refuses bookings rather than offering slots it cannot honour - That cancellation links are random hashed tokens scoped to one booking and rate limited ===== AGENTS.md ===== # Agent instructions · SimplyBook.me indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and one background worker for sync, SQLite with a unique constraint that makes an overlapping hold impossible, One provider, free and busy queries plus incremental sync, Instants stored in UTC, working hours stored as local rules plus a zone, A transactional provider, messages sent from an outbox. 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 - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ===== BUILD_PLAN.md ===== # Build plan · SimplyBook.me A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 without reading anyone's diary Open slots are derived from busy intervals and working hours, correctly across a clock change. ### Steps 1. Ask for busy intervals, not events What you never fetch cannot leak. Files: `src/lib/calendar/freebusy.ts`, `tests/no-titles.test.ts` 2. Expand working hours from zone rules per date Not from a fixed offset. The two transition days are the test. Files: `src/lib/hours.ts`, `tests/dst.test.ts` 3. Subtract buffers and notice as part of availability A slot you cannot actually take is worse than no slot. Files: `src/lib/availability.ts` ### Done when - [ ] No titles or attendees reach the database - [ ] Both clock change days resolve by a stated rule - [ ] Partial overlaps remove exactly the right slots - [ ] Rendering in another zone does not shift the instant ### Watch out - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. ## Phase 2 · Two people, one slot, one winner Concurrent bookings of the same slot produce one booking and one clear refusal. ### Steps 1. Insert a hold under an overlap constraint The database says no, not your code. Files: `src/lib/holds.ts`, `tests/concurrent-book.test.ts` 2. Write the calendar event only after the hold commits Order matters more than speed here. Files: `src/lib/booking.ts` 3. Expire abandoned holds on a timer A hold nobody completed must not block the slot forever. Files: `worker/expire-holds.ts` ### Done when - [ ] Concurrent requests yield one booking and one refusal - [ ] An abandoned hold expires and frees the slot - [ ] A failed calendar write leaves no booking - [ ] No path books without a hold ### Watch out - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. ## Phase 3 · Keeping the mirror honest An event added in the calendar app appears in your busy blocks before somebody books over it. ### Steps 1. Treat token invalidation as a defined path Discard, full resync, carry on. Not an exception to log. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 2. Renew the channel before its stored expiry There is no automatic renewal, so the timer is yours. Files: `worker/renew-channel.ts` 3. Refuse bookings while the mirror is stale No slots is honest; wrong slots is a double booking. Files: `src/lib/availability.ts` ### Done when - [ ] A 410 triggers a full resync that matches the calendar - [ ] Channels renew ahead of their stored expiry - [ ] A new calendar event reaches busy blocks in time - [ ] A stale mirror refuses bookings ### Watch out - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. ## Phase 4 · Times people can read, sent once Confirmations and reminders state the time in the invitee's own zone and never arrive twice. ### Steps 1. Capture and name the invitee's zone A time without a zone is a guess with a number on it. Files: `src/lib/timezones.ts`, `tests/rendered-times.test.ts` 2. Compute reminder instants, not local offsets The clock change is the test. Files: `src/lib/reminders.ts` 3. Deliver from an outbox with a delivery identifier A confirmed booking must survive a dead mail provider. Files: `worker/outbox.ts` ### Done when - [ ] Every stated time names its zone - [ ] A reminder across a clock change fires correctly - [ ] An email outage does not undo a booking - [ ] No queued message sends twice ### Watch out - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ## Not in this build - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. ## After v1, if you want it - Group bookings with a capacity per slot, which changes the hold constraint rather than the design - A second calendar read-only for conflicts, once token invalidation has been survived in production - Recurring availability exceptions such as holidays, expressed as the same local rules ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Services, hours, holds, bookings and busy intervals. No titles. DATABASE_PATH=./data/booking.db # Required. OAuth client for the calendar grant. CALENDAR_CLIENT_ID=1234-redacted.apps.googleusercontent.com # Required · secret. Never in the repository. Revoke it to test the pause. CALENDAR_CLIENT_SECRET=redacted # Required. OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. PUBLIC_BASE_URL=https://book.example.com # Required. How long a started booking blocks a slot before the timer releases it. HOLD_EXPIRY_SECONDS=600 # Required. A mirror older than this refuses to offer slots. SYNC_STALE_MINUTES=15 # Required. Renew ahead of the expiry the provider reports, since nothing renews itself. CHANNEL_RENEW_MARGIN_MINUTES=120 # Required · secret. Confirmations and reminders, delivered from the outbox. EMAIL_API_KEY=re_...redacted
You are building a lean indie version of SimplyBook.me. 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 ===== # SimplyBook.me · indie build A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and one background worker for sync | renewal and reconciliation cannot live in a request | | Storage | SQLite with a unique constraint that makes an overlapping hold impossible | the double-booking bug is a constraint you either wrote or did not | | Calendar | One provider, free and busy queries plus incremental sync | titles never enter the database, so they cannot leak | | Time | Instants stored in UTC, working hours stored as local rules plus a zone | a working day is local; a booking is an instant | | Email | A transactional provider, messages sent from an outbox | a failed confirmation must not undo a confirmed booking | ## 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 application and the sync worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with API credentials** · free at personal volume - Why: The whole product is a correct mirror of one calendar, and the credentials decide which scopes you can ask for. - Get it: Create a project with the provider, enable the calendar API, and create OAuth credentials with the narrowest scopes that allow reading free and busy information and creating events. - Verify: A free and busy query returns intervals and an event you create appears in the calendar - [ ] **A public HTTPS callback with a valid certificate** · a few pounds a month - Why: The OAuth redirect and the push notification receiver both need a reachable address with a certificate the provider trusts; self-signed will not do. - Get it: A small host with a domain and a certificate, or a tunnel that terminates TLS properly for development. - Verify: The provider delivers a test notification to the address - [ ] **A transactional email provider key** · free tier is ample - Why: Confirmations and reminders go out through it, and a booking must survive its outage. - Get it: Create an account, verify a sending domain and issue a sending-only key. - Verify: A confirmation arrives and the provider reports it accepted - [ ] **Your buffers, notice, horizon, hold expiry and clock change rule** · free - Why: Each is asserted by a test, and deciding them after watching a failure means picking whatever the code already does. - Get it: Write down buffer before and after, minimum notice, how far ahead people may book, how long a hold lives, and what happens to a slot in the missing hour. - Verify: All five are in the configuration and named in tests ## 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: - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases If one of those is essential to you, that is the reason to keep paying for SimplyBook.me, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · SimplyBook.me 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 reads one calendar, hands out slots, and cannot double-book you to replace SimplyBook.me. 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. ### Rule zero Two rules, and the first is about other people's privacy. Availability is computed from busy intervals only: a start, an end, and nothing else. Never read an event's title, attendees or description into your database and never render one, because a public booking page that shows 'busy: Oncology appointment' is a disclosure rather than a feature. Ask the calendar for free and busy information, not for events, wherever the provider offers that distinction. Second, a slot is held in your own database, inside a transaction, before the calendar is touched. Two people clicking the same slot in the same second is the normal case ten minutes after you send a newsletter, not an edge case, and a check followed by a write is not a lock. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and one background worker for sync · renewal and reconciliation cannot live in a request - Storage: SQLite with a unique constraint that makes an overlapping hold impossible · the double-booking bug is a constraint you either wrote or did not - Calendar: One provider, free and busy queries plus incremental sync · titles never enter the database, so they cannot leak - Time: Instants stored in UTC, working hours stored as local rules plus a zone · a working day is local; a booking is an instant - Email: A transactional provider, messages sent from an outbox · a failed confirmation must not undo a confirmed booking ### Data model (create this before Phase 1) - calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late. ### Phase 1 · Availability without reading anyone's diary Build: Query the calendar for busy intervals over the horizon and store start and end only. Subtract them, plus buffers and minimum notice, from working hours expanded from local rules using the zone's rules for each date. Publish the remaining slots as instants and render them in the viewer's zone. Trap: Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. Done when: No event title, description or attendee is ever written to the database, asserted by a test over the schema and the sync code. Slots on both clock change days resolve by a stated rule, tested with both dates. A busy interval overlapping a working hour removes exactly the overlapping slots, tested with partial overlaps at both ends. Slots render in the viewer's zone and the underlying instant is unchanged by that rendering. Do not build yet: No booking, holds, calendar writes or email. ### Phase 2 · Two people, one slot, one winner Build: Booking inserts a hold row with an expiry inside a transaction, guarded by a constraint that refuses an overlapping hold for the same service. Only after the hold commits do you write the calendar event and promote the hold to a booking. Expire abandoned holds on a timer. Trap: Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. Done when: Two concurrent requests for the same slot produce one booking and one refusal, tested by firing both from the same test. An abandoned hold expires and the slot reappears, tested. A calendar write that fails leaves no booking and releases the hold, tested by making the write fail. No code path books without holding first, verified by review. Do not build yet: No incremental sync, reminders or cancellation yet. ### Phase 3 · Keeping the mirror honest Build: Sync incrementally with the provider's sync token, and subscribe to push notifications so a change wakes the worker rather than waiting for a poll. Handle an invalidated token by discarding local busy blocks and doing a full resync. Renew the notification channel before it expires and reconcile on every start. Trap: Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. Trap: Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. Done when: A 410 response triggers a full resync and the busy blocks afterwards match the calendar, tested by invalidating a token deliberately. A notification channel is renewed before its stored expiry and the new channel identifier differs from the old one. An event created in the calendar app is reflected in busy blocks within a stated interval, tested end to end. Sync failure marks the calendar stale and the booking page refuses new bookings rather than offering slots from an out-of-date mirror. Do not build yet: No reminders, cancellation or export yet. ### Phase 4 · Times people can read, sent once Build: Capture the invitee's zone at booking and render every time in it with the zone named. Queue the confirmation and the reminder in an outbox with a send-after instant, delivered by the worker with backoff. A booking is confirmed whether or not the email succeeds. Trap: Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. Done when: Every time in every message carries a named zone and matches the stored instant, tested by booking from a machine set to a different zone. A reminder scheduled across a clock change fires at the correct instant, tested with both transition dates. An email provider outage leaves the booking confirmed and the message queued, tested. A queued message is never sent twice, asserted with a delivery identifier. Do not build yet: No cancellation, rescheduling or data export yet. ### Phase 5 · Cancelling, leaving and taking your data Build: Issue an unguessable cancellation link per booking that allows cancelling or rescheduling and nothing else. On the first authorisation failure, pause the calendar, stop offering slots and alert once. Export bookings as CSV and iCalendar. Delete an invitee's details on request, including from the calendar event, and keep only the interval. Trap: A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. Done when: A cancellation token is random, stored hashed, scoped to one booking, and an adjacent identifier reveals nothing, tested. Revoking the calendar grant pauses the calendar within one worker cycle, stops the booking page offering slots and alerts once, tested by revoking it. The export re-imports into an empty instance and the iCalendar file opens in a calendar application. A deletion request removes the invitee's name and address from the database and the calendar event, leaving the interval, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Multiple staff with round robin routing, which turns one availability calculation into a fairness algorithm and a second class of double booking. - Taking payment at booking, which adds refunds, disputes and a no-show policy to what is currently a calendar problem. - A second calendar provider until the first one has survived a token invalidation and a channel expiry in production. - A public site builder and a service marketplace, which are the add-on business rather than the booking loop. ### README must contain - That availability comes from busy intervals only, and that no event title or attendee is ever stored - That a slot is held under a database constraint before the calendar is written, because a check followed by a write is not a lock - That an invalidated sync token is a normal event with a defined full resync, and that push channels must be renewed before they expire - That a stale calendar mirror refuses bookings rather than offering slots it cannot honour - That cancellation links are random hashed tokens scoped to one booking and rate limited ===== AGENTS.md ===== # Agent instructions · SimplyBook.me indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and one background worker for sync, SQLite with a unique constraint that makes an overlapping hold impossible, One provider, free and busy queries plus incremental sync, Instants stored in UTC, working hours stored as local rules plus a zone, A transactional provider, messages sent from an outbox. 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 - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ===== BUILD_PLAN.md ===== # Build plan · SimplyBook.me A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 without reading anyone's diary Open slots are derived from busy intervals and working hours, correctly across a clock change. ### Steps 1. Ask for busy intervals, not events What you never fetch cannot leak. Files: `src/lib/calendar/freebusy.ts`, `tests/no-titles.test.ts` 2. Expand working hours from zone rules per date Not from a fixed offset. The two transition days are the test. Files: `src/lib/hours.ts`, `tests/dst.test.ts` 3. Subtract buffers and notice as part of availability A slot you cannot actually take is worse than no slot. Files: `src/lib/availability.ts` ### Done when - [ ] No titles or attendees reach the database - [ ] Both clock change days resolve by a stated rule - [ ] Partial overlaps remove exactly the right slots - [ ] Rendering in another zone does not shift the instant ### Watch out - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. ## Phase 2 · Two people, one slot, one winner Concurrent bookings of the same slot produce one booking and one clear refusal. ### Steps 1. Insert a hold under an overlap constraint The database says no, not your code. Files: `src/lib/holds.ts`, `tests/concurrent-book.test.ts` 2. Write the calendar event only after the hold commits Order matters more than speed here. Files: `src/lib/booking.ts` 3. Expire abandoned holds on a timer A hold nobody completed must not block the slot forever. Files: `worker/expire-holds.ts` ### Done when - [ ] Concurrent requests yield one booking and one refusal - [ ] An abandoned hold expires and frees the slot - [ ] A failed calendar write leaves no booking - [ ] No path books without a hold ### Watch out - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. ## Phase 3 · Keeping the mirror honest An event added in the calendar app appears in your busy blocks before somebody books over it. ### Steps 1. Treat token invalidation as a defined path Discard, full resync, carry on. Not an exception to log. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 2. Renew the channel before its stored expiry There is no automatic renewal, so the timer is yours. Files: `worker/renew-channel.ts` 3. Refuse bookings while the mirror is stale No slots is honest; wrong slots is a double booking. Files: `src/lib/availability.ts` ### Done when - [ ] A 410 triggers a full resync that matches the calendar - [ ] Channels renew ahead of their stored expiry - [ ] A new calendar event reaches busy blocks in time - [ ] A stale mirror refuses bookings ### Watch out - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. ## Phase 4 · Times people can read, sent once Confirmations and reminders state the time in the invitee's own zone and never arrive twice. ### Steps 1. Capture and name the invitee's zone A time without a zone is a guess with a number on it. Files: `src/lib/timezones.ts`, `tests/rendered-times.test.ts` 2. Compute reminder instants, not local offsets The clock change is the test. Files: `src/lib/reminders.ts` 3. Deliver from an outbox with a delivery identifier A confirmed booking must survive a dead mail provider. Files: `worker/outbox.ts` ### Done when - [ ] Every stated time names its zone - [ ] A reminder across a clock change fires correctly - [ ] An email outage does not undo a booking - [ ] No queued message sends twice ### Watch out - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ## Not in this build - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. ## After v1, if you want it - Group bookings with a capacity per slot, which changes the hold constraint rather than the design - A second calendar read-only for conflicts, once token invalidation has been survived in production - Recurring availability exceptions such as holidays, expressed as the same local rules ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Services, hours, holds, bookings and busy intervals. No titles. DATABASE_PATH=./data/booking.db # Required. OAuth client for the calendar grant. CALENDAR_CLIENT_ID=1234-redacted.apps.googleusercontent.com # Required · secret. Never in the repository. Revoke it to test the pause. CALENDAR_CLIENT_SECRET=redacted # Required. OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. PUBLIC_BASE_URL=https://book.example.com # Required. How long a started booking blocks a slot before the timer releases it. HOLD_EXPIRY_SECONDS=600 # Required. A mirror older than this refuses to offer slots. SYNC_STALE_MINUTES=15 # Required. Renew ahead of the expiry the provider reports, since nothing renews itself. CHANNEL_RENEW_MARGIN_MINUTES=120 # Required · secret. Confirmations and reminders, delivered from the outbox. EMAIL_API_KEY=re_...redacted
You are building a production product version of SimplyBook.me. 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 ===== # SimplyBook.me · product brief ## Problem The visible service booking loop is buildable, but a credible replacement needs more than the first screen. SimplyBook.me 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 never shows what you are busy with, never gives the same slot to two people, refuses to offer anything while its view of the calendar is stale, and lets an invitee cancel without an account. ## 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 - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases ## Success criteria - No event title or attendee is confirmed absent from the database - Concurrent requests are confirmed to yield one booking - A 410 is confirmed to trigger a full resync - A stale mirror is confirmed to refuse bookings - Cancellation tokens are confirmed random, hashed and scoped ===== BRIEF.md ===== # Build brief · SimplyBook.me 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 reads one calendar, hands out slots, and cannot double-book you to replace SimplyBook.me. 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. ### Rule zero Two rules, and the first is about other people's privacy. Availability is computed from busy intervals only: a start, an end, and nothing else. Never read an event's title, attendees or description into your database and never render one, because a public booking page that shows 'busy: Oncology appointment' is a disclosure rather than a feature. Ask the calendar for free and busy information, not for events, wherever the provider offers that distinction. Second, a slot is held in your own database, inside a transaction, before the calendar is touched. Two people clicking the same slot in the same second is the normal case ten minutes after you send a newsletter, not an edge case, and a check followed by a write is not a lock. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and one background worker for sync · renewal and reconciliation cannot live in a request - Storage: SQLite with a unique constraint that makes an overlapping hold impossible · the double-booking bug is a constraint you either wrote or did not - Calendar: One provider, free and busy queries plus incremental sync · titles never enter the database, so they cannot leak - Time: Instants stored in UTC, working hours stored as local rules plus a zone · a working day is local; a booking is an instant - Email: A transactional provider, messages sent from an outbox · a failed confirmation must not undo a confirmed booking ### Data model (create this before Phase 1) - calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late. ### Phase 1 · Availability without reading anyone's diary Build: Query the calendar for busy intervals over the horizon and store start and end only. Subtract them, plus buffers and minimum notice, from working hours expanded from local rules using the zone's rules for each date. Publish the remaining slots as instants and render them in the viewer's zone. Trap: Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. Done when: No event title, description or attendee is ever written to the database, asserted by a test over the schema and the sync code. Slots on both clock change days resolve by a stated rule, tested with both dates. A busy interval overlapping a working hour removes exactly the overlapping slots, tested with partial overlaps at both ends. Slots render in the viewer's zone and the underlying instant is unchanged by that rendering. Do not build yet: No booking, holds, calendar writes or email. ### Phase 2 · Two people, one slot, one winner Build: Booking inserts a hold row with an expiry inside a transaction, guarded by a constraint that refuses an overlapping hold for the same service. Only after the hold commits do you write the calendar event and promote the hold to a booking. Expire abandoned holds on a timer. Trap: Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. Done when: Two concurrent requests for the same slot produce one booking and one refusal, tested by firing both from the same test. An abandoned hold expires and the slot reappears, tested. A calendar write that fails leaves no booking and releases the hold, tested by making the write fail. No code path books without holding first, verified by review. Do not build yet: No incremental sync, reminders or cancellation yet. ### Phase 3 · Keeping the mirror honest Build: Sync incrementally with the provider's sync token, and subscribe to push notifications so a change wakes the worker rather than waiting for a poll. Handle an invalidated token by discarding local busy blocks and doing a full resync. Renew the notification channel before it expires and reconcile on every start. Trap: Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. Trap: Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. Done when: A 410 response triggers a full resync and the busy blocks afterwards match the calendar, tested by invalidating a token deliberately. A notification channel is renewed before its stored expiry and the new channel identifier differs from the old one. An event created in the calendar app is reflected in busy blocks within a stated interval, tested end to end. Sync failure marks the calendar stale and the booking page refuses new bookings rather than offering slots from an out-of-date mirror. Do not build yet: No reminders, cancellation or export yet. ### Phase 4 · Times people can read, sent once Build: Capture the invitee's zone at booking and render every time in it with the zone named. Queue the confirmation and the reminder in an outbox with a send-after instant, delivered by the worker with backoff. A booking is confirmed whether or not the email succeeds. Trap: Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. Done when: Every time in every message carries a named zone and matches the stored instant, tested by booking from a machine set to a different zone. A reminder scheduled across a clock change fires at the correct instant, tested with both transition dates. An email provider outage leaves the booking confirmed and the message queued, tested. A queued message is never sent twice, asserted with a delivery identifier. Do not build yet: No cancellation, rescheduling or data export yet. ### Phase 5 · Cancelling, leaving and taking your data Build: Issue an unguessable cancellation link per booking that allows cancelling or rescheduling and nothing else. On the first authorisation failure, pause the calendar, stop offering slots and alert once. Export bookings as CSV and iCalendar. Delete an invitee's details on request, including from the calendar event, and keep only the interval. Trap: A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. Done when: A cancellation token is random, stored hashed, scoped to one booking, and an adjacent identifier reveals nothing, tested. Revoking the calendar grant pauses the calendar within one worker cycle, stops the booking page offering slots and alerts once, tested by revoking it. The export re-imports into an empty instance and the iCalendar file opens in a calendar application. A deletion request removes the invitee's name and address from the database and the calendar event, leaving the interval, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Multiple staff with round robin routing, which turns one availability calculation into a fairness algorithm and a second class of double booking. - Taking payment at booking, which adds refunds, disputes and a no-show policy to what is currently a calendar problem. - A second calendar provider until the first one has survived a token invalidation and a channel expiry in production. - A public site builder and a service marketplace, which are the add-on business rather than the booking loop. ### README must contain - That availability comes from busy intervals only, and that no event title or attendee is ever stored - That a slot is held under a database constraint before the calendar is written, because a check followed by a write is not a lock - That an invalidated sync token is a normal event with a defined full resync, and that push channels must be renewed before they expire - That a stale calendar mirror refuses bookings rather than offering slots it cannot honour - That cancellation links are random hashed tokens scoped to one booking and rate limited ===== ARCHITECTURE.md ===== # Architecture · SimplyBook.me ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and one background worker for sync | renewal and reconciliation cannot live in a request | | Storage | SQLite with a unique constraint that makes an overlapping hold impossible | the double-booking bug is a constraint you either wrote or did not | | Calendar | One provider, free and busy queries plus incremental sync | titles never enter the database, so they cannot leak | | Time | Instants stored in UTC, working hours stored as local rules plus a zone | a working day is local; a booking is an instant | | Email | A transactional provider, messages sent from an outbox | a failed confirmation must not undo a confirmed booking | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Busy intervals subtracted from zone-expanded hours. | Any calculation, provided both clock change days stay tested. | | Holds | The overlap constraint that decides the winner. | Not swappable for a check followed by a write. | | Sync | Incremental sync, token invalidation, channel renewal. | Any provider, provided invalidation and expiry are defined paths. | | Outbox | Confirmations and reminders as instants. | Any sender, provided a booking survives its outage. | | Tokens | Random hashed cancellation links. | Any scheme without a guessable identifier in it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Services, hours, holds, bookings and busy intervals. No titles. - `CALENDAR_CLIENT_ID` · required · OAuth client for the calendar grant. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository. Revoke it to test the pause. - `PUBLIC_BASE_URL` · required · OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. - `HOLD_EXPIRY_SECONDS` · required · How long a started booking blocks a slot before the timer releases it. - `SYNC_STALE_MINUTES` · required · A mirror older than this refuses to offer slots. - `CHANNEL_RENEW_MARGIN_MINUTES` · required · Renew ahead of the expiry the provider reports, since nothing renews itself. - `EMAIL_API_KEY` · required, secret · Confirmations and reminders, delivered from the outbox. ## 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 · SimplyBook.me product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and one background worker for sync, SQLite with a unique constraint that makes an overlapping hold impossible, One provider, free and busy queries plus incremental sync, Instants stored in UTC, working hours stored as local rules plus a zone, A transactional provider, messages sent from an outbox. - 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 - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. - A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. ===== MILESTONES.md ===== # Delivery milestones · SimplyBook.me Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability without reading anyone's diary Open slots are derived from busy intervals and working hours, correctly across a clock change. ### Steps 1. Ask for busy intervals, not events What you never fetch cannot leak. Files: `src/lib/calendar/freebusy.ts`, `tests/no-titles.test.ts` 2. Expand working hours from zone rules per date Not from a fixed offset. The two transition days are the test. Files: `src/lib/hours.ts`, `tests/dst.test.ts` 3. Subtract buffers and notice as part of availability A slot you cannot actually take is worse than no slot. Files: `src/lib/availability.ts` ### Done when - [ ] No titles or attendees reach the database - [ ] Both clock change days resolve by a stated rule - [ ] Partial overlaps remove exactly the right slots - [ ] Rendering in another zone does not shift the instant ### Watch out - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. ## M2 · Two people, one slot, one winner Concurrent bookings of the same slot produce one booking and one clear refusal. ### Steps 1. Insert a hold under an overlap constraint The database says no, not your code. Files: `src/lib/holds.ts`, `tests/concurrent-book.test.ts` 2. Write the calendar event only after the hold commits Order matters more than speed here. Files: `src/lib/booking.ts` 3. Expire abandoned holds on a timer A hold nobody completed must not block the slot forever. Files: `worker/expire-holds.ts` ### Done when - [ ] Concurrent requests yield one booking and one refusal - [ ] An abandoned hold expires and frees the slot - [ ] A failed calendar write leaves no booking - [ ] No path books without a hold ### Watch out - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. ## M3 · Keeping the mirror honest An event added in the calendar app appears in your busy blocks before somebody books over it. ### Steps 1. Treat token invalidation as a defined path Discard, full resync, carry on. Not an exception to log. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 2. Renew the channel before its stored expiry There is no automatic renewal, so the timer is yours. Files: `worker/renew-channel.ts` 3. Refuse bookings while the mirror is stale No slots is honest; wrong slots is a double booking. Files: `src/lib/availability.ts` ### Done when - [ ] A 410 triggers a full resync that matches the calendar - [ ] Channels renew ahead of their stored expiry - [ ] A new calendar event reaches busy blocks in time - [ ] A stale mirror refuses bookings ### Watch out - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. ## M4 · Times people can read, sent once Confirmations and reminders state the time in the invitee's own zone and never arrive twice. ### Steps 1. Capture and name the invitee's zone A time without a zone is a guess with a number on it. Files: `src/lib/timezones.ts`, `tests/rendered-times.test.ts` 2. Compute reminder instants, not local offsets The clock change is the test. Files: `src/lib/reminders.ts` 3. Deliver from an outbox with a delivery identifier A confirmed booking must survive a dead mail provider. Files: `worker/outbox.ts` ### Done when - [ ] Every stated time names its zone - [ ] A reminder across a clock change fires correctly - [ ] An email outage does not undo a booking - [ ] No queued message sends twice ### Watch out - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ## M5 · Cancelling, leaving and taking your data (production only) An invitee can cancel without an account, a revoked calendar grant is handled, and nothing is trapped. ### Steps 1. Random hashed tokens, scoped and rate limited A guessable cancellation link is a denial of service with manners. Files: `src/lib/tokens.ts`, `tests/token-scope.test.ts` 2. Pause on the first authorisation failure Offering slots you cannot honour is worse than offering none. Files: `worker/sync.ts`, `tests/revoked.test.ts` 3. Export and delete, both properly The calendar event holds personal data too. Files: `src/lib/export.ts`, `src/lib/erase.ts` ### Done when - [ ] Cancellation tokens are random, hashed and scoped - [ ] A revoked grant pauses the page and alerts once - [ ] The export re-imports and the iCalendar file opens - [ ] Deletion clears the calendar event as well ### Watch out - A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. ===== OPERATIONS.md ===== # Operations · SimplyBook.me ## Backup The database, which holds bookings, holds and the busy mirror. The mirror can be rebuilt from the calendar but the bookings and invitee details cannot, so the backup exists for them. ## Restore Restore, discard the sync token and busy blocks, run a full resync, and only then start offering slots. Restoring a stale mirror and serving from it is how a restore causes a double booking. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar state and staleness, channel expiry approaching, holds that never expired, and outbox depth. Staleness is the one that matters, because every other failure is visible and this one silently offers wrong slots. ## Incident checklist A double booking means something booked without a hold, and the holds table shows the sequence. A gap in reminders means the outbox is stuck or the calendar paused, both visible on one page. A missed appointment usually means a time was rendered without its zone. 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 - [ ] No event title or attendee is confirmed absent from the database - [ ] Concurrent requests are confirmed to yield one booking - [ ] A 410 is confirmed to trigger a full resync - [ ] A stale mirror is confirmed to refuse bookings - [ ] Cancellation tokens are confirmed random, hashed and scoped ## Launch constraint Do not market omitted SimplyBook.me 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. Services, hours, holds, bookings and busy intervals. No titles. DATABASE_PATH=./data/booking.db # Required. OAuth client for the calendar grant. CALENDAR_CLIENT_ID=1234-redacted.apps.googleusercontent.com # Required · secret. Never in the repository. Revoke it to test the pause. CALENDAR_CLIENT_SECRET=redacted # Required. OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. PUBLIC_BASE_URL=https://book.example.com # Required. How long a started booking blocks a slot before the timer releases it. HOLD_EXPIRY_SECONDS=600 # Required. A mirror older than this refuses to offer slots. SYNC_STALE_MINUTES=15 # Required. Renew ahead of the expiry the provider reports, since nothing renews itself. CHANNEL_RENEW_MARGIN_MINUTES=120 # Required · secret. Confirmations and reminders, delivered from the outbox. EMAIL_API_KEY=re_...redacted
# SimplyBook.me · indie build A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and one background worker for sync | renewal and reconciliation cannot live in a request | | Storage | SQLite with a unique constraint that makes an overlapping hold impossible | the double-booking bug is a constraint you either wrote or did not | | Calendar | One provider, free and busy queries plus incremental sync | titles never enter the database, so they cannot leak | | Time | Instants stored in UTC, working hours stored as local rules plus a zone | a working day is local; a booking is an instant | | Email | A transactional provider, messages sent from an outbox | a failed confirmation must not undo a confirmed booking | ## 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 application and the sync worker. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A calendar account with API credentials** · free at personal volume - Why: The whole product is a correct mirror of one calendar, and the credentials decide which scopes you can ask for. - Get it: Create a project with the provider, enable the calendar API, and create OAuth credentials with the narrowest scopes that allow reading free and busy information and creating events. - Verify: A free and busy query returns intervals and an event you create appears in the calendar - [ ] **A public HTTPS callback with a valid certificate** · a few pounds a month - Why: The OAuth redirect and the push notification receiver both need a reachable address with a certificate the provider trusts; self-signed will not do. - Get it: A small host with a domain and a certificate, or a tunnel that terminates TLS properly for development. - Verify: The provider delivers a test notification to the address - [ ] **A transactional email provider key** · free tier is ample - Why: Confirmations and reminders go out through it, and a booking must survive its outage. - Get it: Create an account, verify a sending domain and issue a sending-only key. - Verify: A confirmation arrives and the provider reports it accepted - [ ] **Your buffers, notice, horizon, hold expiry and clock change rule** · free - Why: Each is asserted by a test, and deciding them after watching a failure means picking whatever the code already does. - Get it: Write down buffer before and after, minimum notice, how far ahead people may book, how long a hold lives, and what happens to a slot in the missing hour. - Verify: All five are in the configuration and named in tests ## 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: - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases If one of those is essential to you, that is the reason to keep paying for SimplyBook.me, and the README should say so rather than pretend.
# Build brief · SimplyBook.me 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 reads one calendar, hands out slots, and cannot double-book you to replace SimplyBook.me. 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. ### Rule zero Two rules, and the first is about other people's privacy. Availability is computed from busy intervals only: a start, an end, and nothing else. Never read an event's title, attendees or description into your database and never render one, because a public booking page that shows 'busy: Oncology appointment' is a disclosure rather than a feature. Ask the calendar for free and busy information, not for events, wherever the provider offers that distinction. Second, a slot is held in your own database, inside a transaction, before the calendar is touched. Two people clicking the same slot in the same second is the normal case ten minutes after you send a newsletter, not an edge case, and a check followed by a write is not a lock. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and one background worker for sync · renewal and reconciliation cannot live in a request - Storage: SQLite with a unique constraint that makes an overlapping hold impossible · the double-booking bug is a constraint you either wrote or did not - Calendar: One provider, free and busy queries plus incremental sync · titles never enter the database, so they cannot leak - Time: Instants stored in UTC, working hours stored as local rules plus a zone · a working day is local; a booking is an instant - Email: A transactional provider, messages sent from an outbox · a failed confirmation must not undo a confirmed booking ### Data model (create this before Phase 1) - calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late. ### Phase 1 · Availability without reading anyone's diary Build: Query the calendar for busy intervals over the horizon and store start and end only. Subtract them, plus buffers and minimum notice, from working hours expanded from local rules using the zone's rules for each date. Publish the remaining slots as instants and render them in the viewer's zone. Trap: Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. Done when: No event title, description or attendee is ever written to the database, asserted by a test over the schema and the sync code. Slots on both clock change days resolve by a stated rule, tested with both dates. A busy interval overlapping a working hour removes exactly the overlapping slots, tested with partial overlaps at both ends. Slots render in the viewer's zone and the underlying instant is unchanged by that rendering. Do not build yet: No booking, holds, calendar writes or email. ### Phase 2 · Two people, one slot, one winner Build: Booking inserts a hold row with an expiry inside a transaction, guarded by a constraint that refuses an overlapping hold for the same service. Only after the hold commits do you write the calendar event and promote the hold to a booking. Expire abandoned holds on a timer. Trap: Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. Done when: Two concurrent requests for the same slot produce one booking and one refusal, tested by firing both from the same test. An abandoned hold expires and the slot reappears, tested. A calendar write that fails leaves no booking and releases the hold, tested by making the write fail. No code path books without holding first, verified by review. Do not build yet: No incremental sync, reminders or cancellation yet. ### Phase 3 · Keeping the mirror honest Build: Sync incrementally with the provider's sync token, and subscribe to push notifications so a change wakes the worker rather than waiting for a poll. Handle an invalidated token by discarding local busy blocks and doing a full resync. Renew the notification channel before it expires and reconcile on every start. Trap: Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. Trap: Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. Done when: A 410 response triggers a full resync and the busy blocks afterwards match the calendar, tested by invalidating a token deliberately. A notification channel is renewed before its stored expiry and the new channel identifier differs from the old one. An event created in the calendar app is reflected in busy blocks within a stated interval, tested end to end. Sync failure marks the calendar stale and the booking page refuses new bookings rather than offering slots from an out-of-date mirror. Do not build yet: No reminders, cancellation or export yet. ### Phase 4 · Times people can read, sent once Build: Capture the invitee's zone at booking and render every time in it with the zone named. Queue the confirmation and the reminder in an outbox with a send-after instant, delivered by the worker with backoff. A booking is confirmed whether or not the email succeeds. Trap: Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. Done when: Every time in every message carries a named zone and matches the stored instant, tested by booking from a machine set to a different zone. A reminder scheduled across a clock change fires at the correct instant, tested with both transition dates. An email provider outage leaves the booking confirmed and the message queued, tested. A queued message is never sent twice, asserted with a delivery identifier. Do not build yet: No cancellation, rescheduling or data export yet. ### Phase 5 · Cancelling, leaving and taking your data Build: Issue an unguessable cancellation link per booking that allows cancelling or rescheduling and nothing else. On the first authorisation failure, pause the calendar, stop offering slots and alert once. Export bookings as CSV and iCalendar. Delete an invitee's details on request, including from the calendar event, and keep only the interval. Trap: A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. Done when: A cancellation token is random, stored hashed, scoped to one booking, and an adjacent identifier reveals nothing, tested. Revoking the calendar grant pauses the calendar within one worker cycle, stops the booking page offering slots and alerts once, tested by revoking it. The export re-imports into an empty instance and the iCalendar file opens in a calendar application. A deletion request removes the invitee's name and address from the database and the calendar event, leaving the interval, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Multiple staff with round robin routing, which turns one availability calculation into a fairness algorithm and a second class of double booking. - Taking payment at booking, which adds refunds, disputes and a no-show policy to what is currently a calendar problem. - A second calendar provider until the first one has survived a token invalidation and a channel expiry in production. - A public site builder and a service marketplace, which are the add-on business rather than the booking loop. ### README must contain - That availability comes from busy intervals only, and that no event title or attendee is ever stored - That a slot is held under a database constraint before the calendar is written, because a check followed by a write is not a lock - That an invalidated sync token is a normal event with a defined full resync, and that push channels must be renewed before they expire - That a stale calendar mirror refuses bookings rather than offering slots it cannot honour - That cancellation links are random hashed tokens scoped to one booking and rate limited
# Agent instructions · SimplyBook.me indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript and one background worker for sync, SQLite with a unique constraint that makes an overlapping hold impossible, One provider, free and busy queries plus incremental sync, Instants stored in UTC, working hours stored as local rules plus a zone, A transactional provider, messages sent from an outbox. 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 - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC.
# Build plan · SimplyBook.me A single-owner booking tool whose hard problems are computing availability from busy intervals without leaking what they are, holding a slot atomically so two clicks cannot both win, and keeping a calendar mirror correct through sync token expiry and channel renewal. 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 without reading anyone's diary Open slots are derived from busy intervals and working hours, correctly across a clock change. ### Steps 1. Ask for busy intervals, not events What you never fetch cannot leak. Files: `src/lib/calendar/freebusy.ts`, `tests/no-titles.test.ts` 2. Expand working hours from zone rules per date Not from a fixed offset. The two transition days are the test. Files: `src/lib/hours.ts`, `tests/dst.test.ts` 3. Subtract buffers and notice as part of availability A slot you cannot actually take is worse than no slot. Files: `src/lib/availability.ts` ### Done when - [ ] No titles or attendees reach the database - [ ] Both clock change days resolve by a stated rule - [ ] Partial overlaps remove exactly the right slots - [ ] Rendering in another zone does not shift the instant ### Watch out - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. ## Phase 2 · Two people, one slot, one winner Concurrent bookings of the same slot produce one booking and one clear refusal. ### Steps 1. Insert a hold under an overlap constraint The database says no, not your code. Files: `src/lib/holds.ts`, `tests/concurrent-book.test.ts` 2. Write the calendar event only after the hold commits Order matters more than speed here. Files: `src/lib/booking.ts` 3. Expire abandoned holds on a timer A hold nobody completed must not block the slot forever. Files: `worker/expire-holds.ts` ### Done when - [ ] Concurrent requests yield one booking and one refusal - [ ] An abandoned hold expires and frees the slot - [ ] A failed calendar write leaves no booking - [ ] No path books without a hold ### Watch out - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. ## Phase 3 · Keeping the mirror honest An event added in the calendar app appears in your busy blocks before somebody books over it. ### Steps 1. Treat token invalidation as a defined path Discard, full resync, carry on. Not an exception to log. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 2. Renew the channel before its stored expiry There is no automatic renewal, so the timer is yours. Files: `worker/renew-channel.ts` 3. Refuse bookings while the mirror is stale No slots is honest; wrong slots is a double booking. Files: `src/lib/availability.ts` ### Done when - [ ] A 410 triggers a full resync that matches the calendar - [ ] Channels renew ahead of their stored expiry - [ ] A new calendar event reaches busy blocks in time - [ ] A stale mirror refuses bookings ### Watch out - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. ## Phase 4 · Times people can read, sent once Confirmations and reminders state the time in the invitee's own zone and never arrive twice. ### Steps 1. Capture and name the invitee's zone A time without a zone is a guess with a number on it. Files: `src/lib/timezones.ts`, `tests/rendered-times.test.ts` 2. Compute reminder instants, not local offsets The clock change is the test. Files: `src/lib/reminders.ts` 3. Deliver from an outbox with a delivery identifier A confirmed booking must survive a dead mail provider. Files: `worker/outbox.ts` ### Done when - [ ] Every stated time names its zone - [ ] A reminder across a clock change fires correctly - [ ] An email outage does not undo a booking - [ ] No queued message sends twice ### Watch out - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ## Not in this build - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. ## After v1, if you want it - Group bookings with a capacity per slot, which changes the hold constraint rather than the design - A second calendar read-only for conflicts, once token invalidation has been survived in production - Recurring availability exceptions such as holidays, expressed as the same local rules
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Services, hours, holds, bookings and busy intervals. No titles. DATABASE_PATH=./data/booking.db # Required. OAuth client for the calendar grant. CALENDAR_CLIENT_ID=1234-redacted.apps.googleusercontent.com # Required · secret. Never in the repository. Revoke it to test the pause. CALENDAR_CLIENT_SECRET=redacted # Required. OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. PUBLIC_BASE_URL=https://book.example.com # Required. How long a started booking blocks a slot before the timer releases it. HOLD_EXPIRY_SECONDS=600 # Required. A mirror older than this refuses to offer slots. SYNC_STALE_MINUTES=15 # Required. Renew ahead of the expiry the provider reports, since nothing renews itself. CHANNEL_RENEW_MARGIN_MINUTES=120 # Required · secret. Confirmations and reminders, delivered from the outbox. EMAIL_API_KEY=re_...redacted
# SimplyBook.me · product brief ## Problem The visible service booking loop is buildable, but a credible replacement needs more than the first screen. SimplyBook.me 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 never shows what you are busy with, never gives the same slot to two people, refuses to offer anything while its view of the calendar is stale, and lets an invitee cancel without an account. ## 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 - Multiple staff and round robin routing. - Payments, refunds and no-show policies. - A second calendar provider. - A site builder and a service marketplace. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases ## Success criteria - No event title or attendee is confirmed absent from the database - Concurrent requests are confirmed to yield one booking - A 410 is confirmed to trigger a full resync - A stale mirror is confirmed to refuse bookings - Cancellation tokens are confirmed random, hashed and scoped
# Build brief · SimplyBook.me 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 reads one calendar, hands out slots, and cannot double-book you to replace SimplyBook.me. 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. ### Rule zero Two rules, and the first is about other people's privacy. Availability is computed from busy intervals only: a start, an end, and nothing else. Never read an event's title, attendees or description into your database and never render one, because a public booking page that shows 'busy: Oncology appointment' is a disclosure rather than a feature. Ask the calendar for free and busy information, not for events, wherever the provider offers that distinction. Second, a slot is held in your own database, inside a transaction, before the calendar is touched. Two people clicking the same slot in the same second is the normal case ten minutes after you send a newsletter, not an edge case, and a check followed by a write is not a lock. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript and one background worker for sync · renewal and reconciliation cannot live in a request - Storage: SQLite with a unique constraint that makes an overlapping hold impossible · the double-booking bug is a constraint you either wrote or did not - Calendar: One provider, free and busy queries plus incremental sync · titles never enter the database, so they cannot leak - Time: Instants stored in UTC, working hours stored as local rules plus a zone · a working day is local; a booking is an instant - Email: A transactional provider, messages sent from an outbox · a failed confirmation must not undo a confirmed booking ### Data model (create this before Phase 1) - calendars(id, provider, account_email, token_ref, sync_token, sync_token_at, channel_id, channel_expires_at, state, paused_reason) - busy_blocks(calendar_id, starts_at, ends_at, source_event_id) · times only, never a title, never an attendee - services(id, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days) - hours(id, weekday, start_local, end_local, timezone) · the owner's working hours as local rules, not as instants - holds(id, service_id, starts_at, ends_at, expires_at, booking_id) · a short-lived claim with an exclusion constraint on overlap - bookings(id, service_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, remote_event_id, state, cancel_token_hash, created_at) - outbox(id, booking_id, kind, send_after, attempts, delivered_at, last_error) · confirmations, reminders and cancellations - The hold table is the lock. Nothing about the calendar is trusted to prevent a collision, because the calendar is a remote system you learn about late. ### Phase 1 · Availability without reading anyone's diary Build: Query the calendar for busy intervals over the horizon and store start and end only. Subtract them, plus buffers and minimum notice, from working hours expanded from local rules using the zone's rules for each date. Publish the remaining slots as instants and render them in the viewer's zone. Trap: Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. Done when: No event title, description or attendee is ever written to the database, asserted by a test over the schema and the sync code. Slots on both clock change days resolve by a stated rule, tested with both dates. A busy interval overlapping a working hour removes exactly the overlapping slots, tested with partial overlaps at both ends. Slots render in the viewer's zone and the underlying instant is unchanged by that rendering. Do not build yet: No booking, holds, calendar writes or email. ### Phase 2 · Two people, one slot, one winner Build: Booking inserts a hold row with an expiry inside a transaction, guarded by a constraint that refuses an overlapping hold for the same service. Only after the hold commits do you write the calendar event and promote the hold to a booking. Expire abandoned holds on a timer. Trap: Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. Done when: Two concurrent requests for the same slot produce one booking and one refusal, tested by firing both from the same test. An abandoned hold expires and the slot reappears, tested. A calendar write that fails leaves no booking and releases the hold, tested by making the write fail. No code path books without holding first, verified by review. Do not build yet: No incremental sync, reminders or cancellation yet. ### Phase 3 · Keeping the mirror honest Build: Sync incrementally with the provider's sync token, and subscribe to push notifications so a change wakes the worker rather than waiting for a poll. Handle an invalidated token by discarding local busy blocks and doing a full resync. Renew the notification channel before it expires and reconcile on every start. Trap: Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. Trap: Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. Done when: A 410 response triggers a full resync and the busy blocks afterwards match the calendar, tested by invalidating a token deliberately. A notification channel is renewed before its stored expiry and the new channel identifier differs from the old one. An event created in the calendar app is reflected in busy blocks within a stated interval, tested end to end. Sync failure marks the calendar stale and the booking page refuses new bookings rather than offering slots from an out-of-date mirror. Do not build yet: No reminders, cancellation or export yet. ### Phase 4 · Times people can read, sent once Build: Capture the invitee's zone at booking and render every time in it with the zone named. Queue the confirmation and the reminder in an outbox with a send-after instant, delivered by the worker with backoff. A booking is confirmed whether or not the email succeeds. Trap: Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. Done when: Every time in every message carries a named zone and matches the stored instant, tested by booking from a machine set to a different zone. A reminder scheduled across a clock change fires at the correct instant, tested with both transition dates. An email provider outage leaves the booking confirmed and the message queued, tested. A queued message is never sent twice, asserted with a delivery identifier. Do not build yet: No cancellation, rescheduling or data export yet. ### Phase 5 · Cancelling, leaving and taking your data Build: Issue an unguessable cancellation link per booking that allows cancelling or rescheduling and nothing else. On the first authorisation failure, pause the calendar, stop offering slots and alert once. Export bookings as CSV and iCalendar. Delete an invitee's details on request, including from the calendar event, and keep only the interval. Trap: A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint. Done when: A cancellation token is random, stored hashed, scoped to one booking, and an adjacent identifier reveals nothing, tested. Revoking the calendar grant pauses the calendar within one worker cycle, stops the booking page offering slots and alerts once, tested by revoking it. The export re-imports into an empty instance and the iCalendar file opens in a calendar application. A deletion request removes the invitee's name and address from the database and the calendar event, leaving the interval, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Multiple staff with round robin routing, which turns one availability calculation into a fairness algorithm and a second class of double booking. - Taking payment at booking, which adds refunds, disputes and a no-show policy to what is currently a calendar problem. - A second calendar provider until the first one has survived a token invalidation and a channel expiry in production. - A public site builder and a service marketplace, which are the add-on business rather than the booking loop. ### README must contain - That availability comes from busy intervals only, and that no event title or attendee is ever stored - That a slot is held under a database constraint before the calendar is written, because a check followed by a write is not a lock - That an invalidated sync token is a normal event with a defined full resync, and that push channels must be renewed before they expire - That a stale calendar mirror refuses bookings rather than offering slots it cannot honour - That cancellation links are random hashed tokens scoped to one booking and rate limited
# Architecture · SimplyBook.me ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript and one background worker for sync | renewal and reconciliation cannot live in a request | | Storage | SQLite with a unique constraint that makes an overlapping hold impossible | the double-booking bug is a constraint you either wrote or did not | | Calendar | One provider, free and busy queries plus incremental sync | titles never enter the database, so they cannot leak | | Time | Instants stored in UTC, working hours stored as local rules plus a zone | a working day is local; a booking is an instant | | Email | A transactional provider, messages sent from an outbox | a failed confirmation must not undo a confirmed booking | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Busy intervals subtracted from zone-expanded hours. | Any calculation, provided both clock change days stay tested. | | Holds | The overlap constraint that decides the winner. | Not swappable for a check followed by a write. | | Sync | Incremental sync, token invalidation, channel renewal. | Any provider, provided invalidation and expiry are defined paths. | | Outbox | Confirmations and reminders as instants. | Any sender, provided a booking survives its outage. | | Tokens | Random hashed cancellation links. | Any scheme without a guessable identifier in it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Services, hours, holds, bookings and busy intervals. No titles. - `CALENDAR_CLIENT_ID` · required · OAuth client for the calendar grant. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository. Revoke it to test the pause. - `PUBLIC_BASE_URL` · required · OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. - `HOLD_EXPIRY_SECONDS` · required · How long a started booking blocks a slot before the timer releases it. - `SYNC_STALE_MINUTES` · required · A mirror older than this refuses to offer slots. - `CHANNEL_RENEW_MARGIN_MINUTES` · required · Renew ahead of the expiry the provider reports, since nothing renews itself. - `EMAIL_API_KEY` · required, secret · Confirmations and reminders, delivered from the outbox. ## 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 · SimplyBook.me product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript and one background worker for sync, SQLite with a unique constraint that makes an overlapping hold impossible, One provider, free and busy queries plus incremental sync, Instants stored in UTC, working hours stored as local rules plus a zone, A transactional provider, messages sent from an outbox. - 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 - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. - A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint.
# Delivery milestones · SimplyBook.me Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability without reading anyone's diary Open slots are derived from busy intervals and working hours, correctly across a clock change. ### Steps 1. Ask for busy intervals, not events What you never fetch cannot leak. Files: `src/lib/calendar/freebusy.ts`, `tests/no-titles.test.ts` 2. Expand working hours from zone rules per date Not from a fixed offset. The two transition days are the test. Files: `src/lib/hours.ts`, `tests/dst.test.ts` 3. Subtract buffers and notice as part of availability A slot you cannot actually take is worse than no slot. Files: `src/lib/availability.ts` ### Done when - [ ] No titles or attendees reach the database - [ ] Both clock change days resolve by a stated rule - [ ] Partial overlaps remove exactly the right slots - [ ] Rendering in another zone does not shift the instant ### Watch out - Expanding working hours by adding a fixed offset to local times gives you slots an hour wrong for the fortnight after a clock change, and on the two transition days it produces either a slot in an hour that does not exist or two slots in an hour that happens twice. Expand from the zone's rules for each specific date and write a test for both transition dates before anything else works. ## M2 · Two people, one slot, one winner Concurrent bookings of the same slot produce one booking and one clear refusal. ### Steps 1. Insert a hold under an overlap constraint The database says no, not your code. Files: `src/lib/holds.ts`, `tests/concurrent-book.test.ts` 2. Write the calendar event only after the hold commits Order matters more than speed here. Files: `src/lib/booking.ts` 3. Expire abandoned holds on a timer A hold nobody completed must not block the slot forever. Files: `worker/expire-holds.ts` ### Done when - [ ] Concurrent requests yield one booking and one refusal - [ ] An abandoned hold expires and frees the slot - [ ] A failed calendar write leaves no booking - [ ] No path books without a hold ### Watch out - Checking whether a slot is free and then writing the event leaves a gap between the two in which the other person does exactly the same, and both get a confirmation email for the same half hour; the gap is small enough that you will never see it in manual testing and large enough that it happens the first time you post the link somewhere. Insert the hold under a constraint, and let the database refuse the second one. ## M3 · Keeping the mirror honest An event added in the calendar app appears in your busy blocks before somebody books over it. ### Steps 1. Treat token invalidation as a defined path Discard, full resync, carry on. Not an exception to log. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 2. Renew the channel before its stored expiry There is no automatic renewal, so the timer is yours. Files: `worker/renew-channel.ts` 3. Refuse bookings while the mirror is stale No slots is honest; wrong slots is a double booking. Files: `src/lib/availability.ts` ### Done when - [ ] A 410 triggers a full resync that matches the calendar - [ ] Channels renew ahead of their stored expiry - [ ] A new calendar event reaches busy blocks in time - [ ] A stale mirror refuses bookings ### Watch out - Storing a sync token and using it forever works until the day it does not: the Google Calendar API answers an expired or invalidated token with HTTP 410 Gone and expects you to clear local storage and perform a fresh full sync (checked September 2026), and it answers a request whose query parameters differ from the initial one with 400. Treat 410 as a normal event with a defined recovery path, keep the parameters identical across the sequence, and never fall back to serving stale busy blocks. - Subscribing to push notifications once and assuming they keep arriving fails quietly, because the channel has an expiry and there is no automatic renewal: you must call watch again with a new unique channel identifier before the old one lapses (checked September 2026). Store the expiry the provider reports, renew ahead of it, and reconcile with a full query on every worker start so a missed window is bounded. ## M4 · Times people can read, sent once Confirmations and reminders state the time in the invitee's own zone and never arrive twice. ### Steps 1. Capture and name the invitee's zone A time without a zone is a guess with a number on it. Files: `src/lib/timezones.ts`, `tests/rendered-times.test.ts` 2. Compute reminder instants, not local offsets The clock change is the test. Files: `src/lib/reminders.ts` 3. Deliver from an outbox with a delivery identifier A confirmed booking must survive a dead mail provider. Files: `worker/outbox.ts` ### Done when - [ ] Every stated time names its zone - [ ] A reminder across a clock change fires correctly - [ ] An email outage does not undo a booking - [ ] No queued message sends twice ### Watch out - Writing 'your call is at 3pm' with no zone, from a server running in a different one, sends somebody to a call that already happened; and computing a reminder as 'twenty four hours before' in local time sends it an hour early across a clock change. Store the invitee's zone, name it in every message, and compute reminder instants in UTC. ## M5 · Cancelling, leaving and taking your data (production only) An invitee can cancel without an account, a revoked calendar grant is handled, and nothing is trapped. ### Steps 1. Random hashed tokens, scoped and rate limited A guessable cancellation link is a denial of service with manners. Files: `src/lib/tokens.ts`, `tests/token-scope.test.ts` 2. Pause on the first authorisation failure Offering slots you cannot honour is worse than offering none. Files: `worker/sync.ts`, `tests/revoked.test.ts` 3. Export and delete, both properly The calendar event holds personal data too. Files: `src/lib/export.ts`, `src/lib/erase.ts` ### Done when - [ ] Cancellation tokens are random, hashed and scoped - [ ] A revoked grant pauses the page and alerts once - [ ] The export re-imports and the iCalendar file opens - [ ] Deletion clears the calendar event as well ### Watch out - A cancellation link built from the booking's numeric identifier lets anyone walk the range and cancel every appointment you have, and you find out when nobody turns up all week. Use a random token, store only its hash, scope it to the one booking, and rate limit the endpoint.
# Operations · SimplyBook.me ## Backup The database, which holds bookings, holds and the busy mirror. The mirror can be rebuilt from the calendar but the bookings and invitee details cannot, so the backup exists for them. ## Restore Restore, discard the sync token and busy blocks, run a full resync, and only then start offering slots. Restoring a stale mirror and serving from it is how a restore causes a double booking. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar state and staleness, channel expiry approaching, holds that never expired, and outbox depth. Staleness is the one that matters, because every other failure is visible and this one silently offers wrong slots. ## Incident checklist A double booking means something booked without a hold, and the holds table shows the sequence. A gap in reminders means the outbox is stuck or the calendar paused, both visible on one page. A missed appointment usually means a time was rendered without its zone. 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 - [ ] No event title or attendee is confirmed absent from the database - [ ] Concurrent requests are confirmed to yield one booking - [ ] A 410 is confirmed to trigger a full resync - [ ] A stale mirror is confirmed to refuse bookings - [ ] Cancellation tokens are confirmed random, hashed and scoped ## Launch constraint Do not market omitted SimplyBook.me 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. Services, hours, holds, bookings and busy intervals. No titles. DATABASE_PATH=./data/booking.db # Required. OAuth client for the calendar grant. CALENDAR_CLIENT_ID=1234-redacted.apps.googleusercontent.com # Required · secret. Never in the repository. Revoke it to test the pause. CALENDAR_CLIENT_SECRET=redacted # Required. OAuth redirect and the push notification receiver. HTTPS with a trusted certificate. PUBLIC_BASE_URL=https://book.example.com # Required. How long a started booking blocks a slot before the timer releases it. HOLD_EXPIRY_SECONDS=600 # Required. A mirror older than this refuses to offer slots. SYNC_STALE_MINUTES=15 # Required. Renew ahead of the expiry the provider reports, since nothing renews itself. CHANNEL_RENEW_MARGIN_MINUTES=120 # Required · secret. Confirmations and reminders, delivered from the outbox. EMAIL_API_KEY=re_...redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
SimplyBook.me: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xmulti-calendar conflict handling
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
Don't feel like building it? These folks already made it free.
all 3 free alternatives to SimplyBook.me →· no votes, no pay-to-list · just what's real
SimplyBook.me pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | 50 bookings; 1 custom feature; 1 provider. |
| basic | $13.90 | $11.90 | 100 bookings; 3 custom features; 5 providers. |
| standard | $29.90 | $24.90 | 500 bookings; 8 custom features; 15 providers. |
| premium | $59.90 | $49.90 | 2,000 bookings; unlimited custom features; 30 providers. |
| enterprise | custom | — | Custom bookings and providers; unlimited custom features. |
free tier50 bookings; 1 custom feature; 1 provider.
billingmonthly + annual (-17%); Enterprise custom
hidden costsBooking, provider, and custom-feature caps can force a plan upgrade; SMS and payment-processor charges are separate where used.
verified 2026-08-14 · source ↗
Vibecode SimplyBook.me
Kinda. The core of SimplyBook.me is buildable in a weekend with the prompt on this page, but there are real gaps: multi-calendar conflict handling, race-free booking under load. Read the honest list above before committing.
How much does SimplyBook.me cost?
SimplyBook.me's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing SimplyBook.me?
Honestly: multi-calendar conflict handling; race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to SimplyBook.me?
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 3 curated free alternatives are at vibecodeit.com/simplybook-me/alternatives. The prompt is for when you want it exactly your way.