Build Calday
A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong.
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 a version manager. open ↗
Verify
node --version prints v22 or higher - API keyfree
Why Free/busy is a narrower permission than reading events, and asking for the narrower one is the difference between a booking page that cannot leak your day and one that can.
Get it Create a project in the provider's console, configure the consent screen and register the callback URL.
Verify
A free/busy call returns intervals and a call for event details is refused by the scope - have readya few pounds a month, or free on a small tier
Why The OAuth callback and the person booking both need to reach it, so a laptop behind a router will not do once this is real.
Get it A small host or a tunnel with a stable hostname.
Verify
The callback completes and the booking page loads from another network - API keyfree at personal volume
Why Confirmations and cancellations are the part people notice, and sending them from a home address means they land in spam.
Get it Any provider with an API and a verified sending domain.
Verify
A test confirmation with a calendar attachment arrives and the attachment opens - decidefree
Why Daylight saving bugs only appear on transition weeks and only in zones that have them, so pick the two zones your tests will cover before writing the expansion code.
Get it Choose your own zone plus one in the opposite hemisphere.
Verify
Both appear in the test suite with a spring and an autumn case
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier.
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 | Links, rules, holds and bookings. Back this up; it is the lock. |
CALENDAR_CLIENT_ID | required | 123456789-abc.apps.example.com | The registered application. Public half of the OAuth pair. |
CALENDAR_CLIENT_SECRETsecret | required | secret-...redacted | Never in the repository, never in a client bundle. |
PUBLIC_BASE_URL | required | https://book.example.com | Used for the callback and in every link you email. Must match the registered callback. |
EMAIL_API_KEYsecret | required | re_...redacted | Transactional email. Confirmations, cancellations and reschedules. |
FREEBUSY_TTL_SECONDS | required | 120 | How long cached busy intervals are fresh. Past this they are stale, not current. |
HOLD_MINUTES | required | 10 | How long the form holds a slot. Long enough to type, short enough not to block. |
INVITEE_RETENTION_DAYS | required | 365 | After this the name, email and note are removed and the booking stays. |
The build, in order
Availability from rules and free/busy
A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response.
It is the private answer and it is also the correct one.
Files
src/lib/availability.tstests/no-event-details.test.tsTwo clear steps beat one clever query.
Files
src/lib/rules.tstests/rules.test.tsCheck the current cap; do not copy this number from a blog post.
Files
src/lib/freebusy.ts
done when · tick each as it passeswatch out- Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call.
A slot that can only be taken once
Two simultaneous bookings for the same slot end with one confirmation and one clear refusal.
The database is the only lock in this system.
Files
src/lib/book.tssrc/db/schema.sqlIt turns a race into a rare case rather than a common one.
Files
src/lib/holds.tstests/holds.test.tsThe person booked; losing their booking is the worse outcome.
Files
src/lib/book.ts
done when · tick each as it passeswatch out- Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error.
Timezones that survive the clock change
A weekly rule renders correctly for both parties through a daylight saving transition.
One offset for the week is wrong for half of it.
Files
src/lib/expand.tstests/dst.test.tsSkip it, and write down that you skipped it.
Files
src/lib/expand.tsTheir browser may be somewhere else by the time you email them.
Files
src/db/schema.sql
done when · tick each as it passeswatch out- Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone.
Confirmations, cancellations and reschedules
Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost.
Check the current requirement in the calendaring spec your clients read.
Files
src/lib/ics.tstests/ics-sequence.test.tsReleasing then taking can leave both slots gone.
Files
src/lib/reschedule.tsThey disagree about almost everything in this format.
Files
docs/ics-testing.md
done when · tick each as it passeswatch out- Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree.
Tokens, failure and the invitee's dataproduct builder
The page fails closed when the calendar is unreachable, and the personal data it collects has an end date.
No slots and a reason, never a week of open time.
Files
src/lib/availability.tstests/fail-closed.test.tsYou need the history; you do not need their phone number forever.
Files
src/lib/retention.tsThen assert the address appears nowhere.
Files
src/lib/erase.tstests/erase.test.ts
done when · tick each as it passeswatch out- Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week.
That is the whole plan for Calday. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Team routing, round robin and collective availability.
- Payment at booking.
- Text message reminders.
- More than one calendar provider.
- 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 link type with different duration and buffers, which mostly tests whether your rules really are per link
- An availability preview for yourself that shows why a slot is missing, which is the debugging tool you will want first
- Recurring bookings, which are worth doing only after the single-booking path has survived a real clock change
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.