Build zcal

YESback to the verdict

0%0 of 18 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 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.

estimated effort weekendthe files for this build are in the project pack

RuntimeTypeScript on Node 22, one process, SQLiteCalendar readGoogle Calendar freeBusy only, never events.listTimeInstants stored in UTC, zones resolved from the IANA database at displayBookingA local reservation row with a unique constraint, taken before the calendar is touchedCalendar writeevents.insert with a client-supplied base32hex id derived from the reservationMailOne provider behind one send function, with a calendar invitation attached

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

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

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

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

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

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

VariableNeededExampleWhere the value comes from
DATABASE_PATHrequired./data/booking.dbReservations, event types, availability and the busy cache.
GOOGLE_CLIENT_IDrequired1234567890-abcdef.apps.googleusercontent.comFrom the OAuth client. Must match the registered callback.
GOOGLE_CLIENT_SECRETsecretrequiredGOCSPX-...redactedFrom the OAuth client. Never in the repository.
GOOGLE_REDIRECT_URIrequiredhttps://book.example.com/oauth/callbackRegistered on the client exactly, including the scheme and path.
CALENDAR_IDrequiredprimaryThe calendar queried for busy ranges and written to.
PUBLIC_BASE_URLrequiredhttps://book.example.comUsed to build cancel and reschedule links in emails.
SMTP_URLsecretrequiredsmtps://user:redacted@smtp.example.com:465Confirmations and invitations. Credentials only here.
BUSY_CACHE_MAX_AGE_SECONDSrequired900Past this age with no successful refresh, the page stops offering slots.

The build, in order

  1. Busy, without reading anyone's calendar

    Availability is computed from busy ranges, and no event title ever enters your database.

    1. The test is the point: a grep over the schema and the log output.

      Files src/calendar/freebusy.tstests/no-event-contents.test.ts

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

      Files src/availability/compute.tstests/buffers.test.ts

    3. Silent truncation shows you as free during a meeting.

      Files src/calendar/freebusy.ts

    done when · tick each as it passes
    watch 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.
  2. Two timezones and the two awkward days

    A slot shown to the invitee in their zone is the same instant you hold in yours.

    1. A fixed offset is correct for about ten months a year.

      Files src/availability/zones.tstests/dst.test.ts

    2. The commonest scheduling complaint is someone turning up an hour out.

      Files src/templates/book.html

    3. One representation in the database, many on the screen.

      Files src/db/schema.sql

    done when · tick each as it passes
    watch 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.
  3. One slot, one booking

    Two people clicking the same slot produce one event and one clear refusal.

    1. Before the network. This is the actual lock.

      Files src/booking/reserve.tstests/race.test.ts

    2. A hyphenated hexadecimal id is rejected by the alphabet rule.

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

    3. The reference says a duplicate id may not be caught.

      Files src/calendar/reconcile.ts

    done when · tick each as it passes
    watch 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.
  4. Confirmation, reschedule and cancel

    Both sides get a correct confirmation and can change it without guessing a URL.

    1. Sequential identifiers in a public URL are an invitation.

      Files src/tokens.tstests/token-guess.test.ts

    2. People double-click, and mail clients prefetch links.

      Files src/booking/cancel.ts

    3. A half-done reschedule loses the meeting entirely.

      Files src/booking/reschedule.tstests/reschedule.test.ts

    done when · tick each as it passes
    watch 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.
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.