Build zcal
A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out.
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 booking page and the calendar client in one process.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - API keyfree
Why Reading freeBusy and writing events both need an OAuth client, and the consent screen configuration decides how often your refresh token survives.
Get it Create a project, enable the Calendar API, create an OAuth client of type web application and add your callback URL. open ↗
Verify
A consent round trip returns a refresh token and a freeBusy query succeeds - have readyfree
Why The privacy test and the drift test are only meaningful against a calendar containing events whose titles you would mind leaking.
Get it Create a second calendar on the same account and add a few private events across a clock change weekend.
Verify
freeBusy returns busy ranges for it and no titles - have readya few pounds a month
Why The OAuth callback and the public booking page both need an address that does not change, and the callback must match the client exactly.
Get it A small VPS with a domain, or a tunnel with a fixed hostname for development.
Verify
The callback URL registered in the client resolves and completes consent - API keyfree tier is enough for personal use
Why A booking nobody was told about is not a booking, and the invitation attachment is what puts it in the invitee's calendar.
Get it Any transactional provider or an SMTP account you already own.
Verify
A confirmation arrives with a valid calendar invitation attached - decidefree
Why Each one is asserted by a test, and deciding them after seeing which slots appear means choosing whatever the code already did.
Get it Write down your hours per weekday in your zone, the buffers, the minimum notice, the horizon, and what happens to a slot in the missing hour.
Verify
All five appear in configuration and are named in the tests
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens.
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 | Reservations, event types, availability and the busy cache. |
GOOGLE_CLIENT_ID | required | 1234567890-abcdef.apps.googleusercontent.com | From the OAuth client. Must match the registered callback. |
GOOGLE_CLIENT_SECRETsecret | required | GOCSPX-...redacted | From the OAuth client. Never in the repository. |
GOOGLE_REDIRECT_URI | required | https://book.example.com/oauth/callback | Registered on the client exactly, including the scheme and path. |
CALENDAR_ID | required | primary | The calendar queried for busy ranges and written to. |
PUBLIC_BASE_URL | required | https://book.example.com | Used to build cancel and reschedule links in emails. |
SMTP_URLsecret | required | smtps://user:redacted@smtp.example.com:465 | Confirmations and invitations. Credentials only here. |
BUSY_CACHE_MAX_AGE_SECONDS | required | 900 | Past this age with no successful refresh, the page stops offering slots. |
The build, in order
Busy, without reading anyone's calendar
Availability is computed from busy ranges, and no event title ever enters your database.
The test is the point: a grep over the schema and the log output.
Files
src/calendar/freebusy.tstests/no-event-contents.test.tsA slot you cannot actually take is worse than no slot.
Files
src/availability/compute.tstests/buffers.test.tsSilent truncation shows you as free during a meeting.
Files
src/calendar/freebusy.ts
done when · tick each as it passeswatch out- Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage.
Two timezones and the two awkward days
A slot shown to the invitee in their zone is the same instant you hold in yours.
A fixed offset is correct for about ten months a year.
Files
src/availability/zones.tstests/dst.test.tsThe commonest scheduling complaint is someone turning up an hour out.
Files
src/templates/book.htmlOne representation in the database, many on the screen.
Files
src/db/schema.sql
done when · tick each as it passeswatch out- Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones.
One slot, one booking
Two people clicking the same slot produce one event and one clear refusal.
Before the network. This is the actual lock.
Files
src/booking/reserve.tstests/race.test.tsA hyphenated hexadecimal id is rejected by the alphabet rule.
Files
src/calendar/event-id.tstests/event-id.test.tsThe reference says a duplicate id may not be caught.
Files
src/calendar/reconcile.ts
done when · tick each as it passeswatch out- Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry.
Confirmation, reschedule and cancel
Both sides get a correct confirmation and can change it without guessing a URL.
Sequential identifiers in a public URL are an invitation.
Files
src/tokens.tstests/token-guess.test.tsPeople double-click, and mail clients prefetch links.
Files
src/booking/cancel.tsA half-done reschedule loses the meeting entirely.
Files
src/booking/reschedule.tstests/reschedule.test.ts
done when · tick each as it passeswatch out- Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error.
When the token dies and the calendar driftsproduct builder
A stale or broken connection stops offering slots instead of offering wrong ones.
An unavailable page beats a wrong slot.
Files
src/calendar/auth.tstests/stale-cache.test.tsEvents vanish from calendars for reasons you will not see.
Files
src/calendar/reconcile.tsIt is the one endpoint strangers can reach.
Files
src/middleware/ratelimit.ts
done when · tick each as it passeswatch out- Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing.
That is the whole plan for zcal. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Teams, routing and round robin.
- Payments at booking.
- A second calendar provider.
- Hosted booking pages for other people.
- multi-calendar conflict handling
- race-free booking under load
- team routing and round robin
- reliable reminders and timezone edge cases
- calendar-provider edge cases and timezone correctness
- Meeting polls, which reuse the availability engine and add a voting table rather than new calendar work
- A second event type with its own buffers, which the schema already supports and the interface does not yet expose
- Webhook-driven cache invalidation, once the polling version has run for a month without drift
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.