Build Calendbook
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - accountfree 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/booking.db | Event types, availability, bookings. Back this up; it is the only record of who booked what. |
GOOGLE_CLIENT_ID | required | 1234-abc.apps.googleusercontent.com | OAuth client for the Calendar API. |
GOOGLE_CLIENT_SECRETsecret | required | redacted | OAuth client secret. Never in the repository. |
GOOGLE_REFRESH_TOKENsecret | required | 1//redacted | Stored after the consent flow. When this stops working the page pauses. |
ORGANISER_TIMEZONE | required | Europe/London | An IANA zone name, never an offset. Availability rules are wall clock in this zone. |
BUSY_CACHE_SECONDS | required | 120 | How long busy intervals are reused. Shorter means more calls against the per-user quota. |
HOLD_TTL_SECONDS | required | 600 | How long a slot is held while the visitor fills in the form. |
EMAIL_API_KEYsecret | required | re_...redacted | Transactional provider for confirmations and cancellations. |
The build, in order
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.
You should not be storing other people's meeting titles at all.
Files
src/lib/freebusy.tstests/no-event-detail.test.tsThe two transitions are the only interesting test cases here.
Files
src/lib/slots.tstests/dst.test.tsThe stored instant must not move when they do.
Files
src/app/[slug]/page.tsx
done when · tick each as it passeswatch 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.
One slot, one person
Two people submitting the same slot at the same moment produce one booking and one clear refusal.
Application-level checks lose every race they are given.
Files
migrations/002_unique_slot.sqltests/concurrent-book.test.tsThe hold is what stops a slot vanishing while the form is being filled in.
Files
src/lib/holds.tsIt will happen, and it needs copy rather than an error page.
Files
src/app/[slug]/confirm/page.tsx
done when · tick each as it passeswatch 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.
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.
It is the only idempotency mechanism this API offers you.
Files
src/lib/event-id.tstests/event-id.test.tsBecause it means your first attempt worked and you did not hear.
Files
src/lib/calendar-write.tstests/retry-insert.test.tsOne fewer dependency, and you control the zone it names.
Files
src/lib/ics.tstests/ics.test.ts
done when · tick each as it passeswatch 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.
Quota, expiry and the page a stranger seesproduct builder
The booking page behaves when the calendar refuses, the token dies or someone scrapes it, and everything exports.
A public page hit by a link is the quota problem you will meet.
Files
src/lib/freebusy.tssrc/app/[slug]/page.tsxSynchronised retries are how a small problem becomes a sustained one.
Files
src/lib/backoff.tstests/backoff.test.tsOffering a slot you cannot write is worse than offering nothing.
Files
src/lib/auth-state.tstests/token-revoked.test.ts
done when · tick each as it passeswatch out- A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error.
That is the whole plan for Calendbook. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Team routing and round robin.
- Payment at booking.
- Reading full event details from the calendar.
- Automated reminder sequences.
- race-free booking under load
- team routing and round robin
- reliable reminders and timezone edge cases
- calendar-provider edge cases and timezone correctness
- multi-calendar conflict handling
- A second calendar connection for conflict checking only, which is a small change once free/busy is already the read path
- Custom questions per event type, stored in answers_json and included in the export
- A weekly digest of upcoming bookings, which is the one notification that does not need an always-on worker
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.