Build Calendbook

YESback to the verdict

0%0 of 14 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 one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings.

estimated effort one sittingthe files for this build are in the project pack

RuntimeNext.js with a small API layer, one process, no background workerStorageSQLite beside the application, with a unique index doing the concurrency workCalendarOne Google Calendar connection, free/busy for reading and a deterministic event id for writingTimeEvery instant stored in UTC with the zone it was chosen in stored beside itEmailOne transactional provider, with the calendar attachment generated locally

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.

    Get it nodejs.org or your version manager. open ↗

    Verify node --version prints v22 or higher

  2. free at this volume

    Why Reading free/busy and writing an event both need it, and the consent screen configuration is the step that quietly blocks people for an afternoon.

    Get it Create the project, enable the Calendar API, create an OAuth client with your callback URL, and add yourself as a test user. open ↗

    Verify The consent flow completes and a free/busy query returns intervals

  3. have readyfree to a few pounds a month

    Why OAuth will not redirect to a URL that changes every time a tunnel restarts, and this is the most common reason the first connection never completes.

    Get it A small always-on host, or a tunnel with a reserved subdomain.

    Verify The same callback URL works across two restarts

  4. API keyfree at this volume with most providers

    Why Confirmations carry the calendar attachment, and sending them from a home connection means they land in spam.

    Get it Any provider with a free tier; verify the sending domain.

    Verify A confirmation arrives with the attachment intact

  5. decidefree

    Why Availability rules are wall clock in that zone, and the whole daylight saving story depends on there being exactly one answer to this question.

    Get it Write the IANA zone name into configuration, not a fixed offset.

    Verify The configured value is an IANA zone name and the tests use it

Data model

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

- event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active)
- availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five'
- busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored
- holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form
- bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at)
- remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one
- A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it.

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.dbEvent types, availability, bookings. Back this up; it is the only record of who booked what.
GOOGLE_CLIENT_IDrequired1234-abc.apps.googleusercontent.comOAuth client for the Calendar API.
GOOGLE_CLIENT_SECRETsecretrequiredredactedOAuth client secret. Never in the repository.
GOOGLE_REFRESH_TOKENsecretrequired1//redactedStored after the consent flow. When this stops working the page pauses.
ORGANISER_TIMEZONErequiredEurope/LondonAn IANA zone name, never an offset. Availability rules are wall clock in this zone.
BUSY_CACHE_SECONDSrequired120How long busy intervals are reused. Shorter means more calls against the per-user quota.
HOLD_TTL_SECONDSrequired600How long a slot is held while the visitor fills in the form.
EMAIL_API_KEYsecretrequiredre_...redactedTransactional provider for confirmations and cancellations.

The build, in order

  1. Slots that survive the clocks changing

    The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's.

    1. You should not be storing other people's meeting titles at all.

      Files src/lib/freebusy.tstests/no-event-detail.test.ts

    2. The two transitions are the only interesting test cases here.

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

    3. The stored instant must not move when they do.

      Files src/app/[slug]/page.tsx

    done when · tick each as it passes
    watch out
    • Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture.
  2. One slot, one person

    Two people submitting the same slot at the same moment produce one booking and one clear refusal.

    1. Application-level checks lose every race they are given.

      Files migrations/002_unique_slot.sqltests/concurrent-book.test.ts

    2. The hold is what stops a slot vanishing while the form is being filled in.

      Files src/lib/holds.ts

    3. It will happen, and it needs copy rather than an error page.

      Files src/app/[slug]/confirm/page.tsx

    done when · tick each as it passes
    watch out
    • Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500.
  3. A calendar write that a timeout cannot duplicate

    The event is created once whatever the network does, and both parties receive a confirmation with an attachment.

    1. It is the only idempotency mechanism this API offers you.

      Files src/lib/event-id.tstests/event-id.test.ts

    2. Because it means your first attempt worked and you did not hear.

      Files src/lib/calendar-write.tstests/retry-insert.test.ts

    3. One fewer dependency, and you control the zone it names.

      Files src/lib/ics.tstests/ics.test.ts

    done when · tick each as it passes
    watch out
    • Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time.
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.