Build SimplyBook.me

KINDAback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimeNext.js 15 with TypeScript and one background worker for syncStorageSQLite with a unique constraint that makes an overlapping hold impossibleCalendarOne provider, free and busy queries plus incremental syncTimeInstants stored in UTC, working hours stored as local rules plus a zoneEmailA transactional provider, messages sent from an outbox

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the application and the sync worker.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. 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. open ↗

    Verify A free and busy query returns intervals and an event you create appears in the calendar

  3. have readya 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

  4. API keyfree 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

  5. decidefree

    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

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- 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.

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/booking.dbServices, hours, holds, bookings and busy intervals. No titles.
CALENDAR_CLIENT_IDrequired1234-redacted.apps.googleusercontent.comOAuth client for the calendar grant.
CALENDAR_CLIENT_SECRETsecretrequiredredactedNever in the repository. Revoke it to test the pause.
PUBLIC_BASE_URLrequiredhttps://book.example.comOAuth redirect and the push notification receiver. HTTPS with a trusted certificate.
HOLD_EXPIRY_SECONDSrequired600How long a started booking blocks a slot before the timer releases it.
SYNC_STALE_MINUTESrequired15A mirror older than this refuses to offer slots.
CHANNEL_RENEW_MARGIN_MINUTESrequired120Renew ahead of the expiry the provider reports, since nothing renews itself.
EMAIL_API_KEYsecretrequiredre_...redactedConfirmations and reminders, delivered from the outbox.

The build, in order

  1. Availability without reading anyone's diary

    Open slots are derived from busy intervals and working hours, correctly across a clock change.

    1. What you never fetch cannot leak.

      Files src/lib/calendar/freebusy.tstests/no-titles.test.ts

    2. Not from a fixed offset. The two transition days are the test.

      Files src/lib/hours.tstests/dst.test.ts

    3. A slot you cannot actually take is worse than no slot.

      Files src/lib/availability.ts

    done when · tick each as it passes
    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.
  2. Two people, one slot, one winner

    Concurrent bookings of the same slot produce one booking and one clear refusal.

    1. The database says no, not your code.

      Files src/lib/holds.tstests/concurrent-book.test.ts

    2. Order matters more than speed here.

      Files src/lib/booking.ts

    3. A hold nobody completed must not block the slot forever.

      Files worker/expire-holds.ts

    done when · tick each as it passes
    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.
  3. Keeping the mirror honest

    An event added in the calendar app appears in your busy blocks before somebody books over it.

    1. Discard, full resync, carry on. Not an exception to log.

      Files src/lib/calendar/sync.tstests/sync-410.test.ts

    2. There is no automatic renewal, so the timer is yours.

      Files worker/renew-channel.ts

    3. No slots is honest; wrong slots is a double booking.

      Files src/lib/availability.ts

    done when · tick each as it passes
    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.
  4. Times people can read, sent once

    Confirmations and reminders state the time in the invitee's own zone and never arrive twice.

    1. A time without a zone is a guess with a number on it.

      Files src/lib/timezones.tstests/rendered-times.test.ts

    2. The clock change is the test.

      Files src/lib/reminders.ts

    3. A confirmed booking must survive a dead mail provider.

      Files worker/outbox.ts

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.