Build Book Like A Boss
KINDAback to the verdict
A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals.
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 and the outbox worker.
Get it nodejs.org or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why The exclusion constraint is the whole double-booking defence and no other database in reach offers it.
Get it Install locally or use a managed instance, then run CREATE EXTENSION btree_gist. open ↗
Verify
The extension creates and a deliberately overlapping insert is rejected - accountfree
Why Availability that ignores your real calendar is the failure this build exists to prevent.
Get it Create OAuth credentials for your calendar provider with the narrowest scope that returns busy intervals.
Verify
A busy interval you created by hand appears in the cache with no title - API keyfree tier is usually enough for a personal page
Why Confirmations are the part clients notice, and a provider that silently drops them is indistinguishable from a broken booking.
Get it Any provider with an API key and a verified sender address.
Verify
A queued outbox row is sent and marked, and a failing key leaves it visibly stuck - decidefree
Why Two days a year a rule's local start either does not happen or happens twice, and deciding this at the moment it breaks means deciding it badly.
Get it Write down what the app does on each, and turn both into tests.
Verify
Both tests exist and the README states the policy
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books.
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_URLsecret | required | postgres://localhost:5432/booking | PostgreSQL with btree_gist installed. The constraint lives here. |
RESOURCE_ZONE | required | Europe/London | The IANA zone your availability rules are written in. Not an offset. |
CALENDAR_CLIENT_ID | required | 1234-abcd.apps.googleusercontent.com | OAuth client for free and busy access. |
CALENDAR_CLIENT_SECRETsecret | required | GOCSPX-...redacted | Paired with the client id. Never in the repository. |
BUSY_MAX_AGE_MINUTES | required | 15 | Past this the busy cache counts as no data and the page stops offering slots. |
MAIL_API_KEYsecret | required | re_...redacted | Transactional mail. The outbox worker is the only caller. |
LINK_SIGNING_KEYsecret | required | a-long-random-string-redacted | Signs cancellation and reschedule links. Rotating it invalidates outstanding links. |
BOOKING_HORIZON_DAYS | required | 60 | How far ahead the page will offer anything. |
The build, in order
Availability computed from rules, not stored as slots
A week of slots is derived from local-time rules and survives a daylight saving change.
An offset is only true until the next transition.
Files
src/db/schema.sqlsrc/availability/rules.tsThe expansion is the part that has to be aware of the transition.
Files
src/availability/expand.tstests/dst.test.tsBoth sides of a booking should be able to see which clock it means.
Files
src/views/book.ejs
done when · tick each as it passeswatch out- Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition.
The database refuses the overlap
Two people submitting the same slot at the same moment produce one booking and one clear refusal.
This is the single most important line in the schema.
Files
src/db/schema.sqltests/concurrency.test.tsOtherwise back-to-back bookings leave you no walk between them.
Files
src/booking/create.tsThe person is still there and still wants a time.
Files
src/booking/create.tssrc/views/taken.ejs
done when · tick each as it passeswatch out- Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused.
Busy in, events out, and nothing else
The connected calendar contributes intervals only, and the booking appears in it correctly.
The schema is the enforcement; the query is only the intent.
Files
src/calendar/freebusy.tssrc/db/schema.sqlA floating time in someone else's calendar means a different hour.
Files
src/calendar/write.tsIt is what makes the second write an update instead of a duplicate.
Files
src/calendar/write.tstests/sequence.test.ts
done when · tick each as it passeswatch out- Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times.
Confirmations that actually leave, and a way to cancel
Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved.
Either both happened or neither did.
Files
src/outbox/enqueue.tssrc/booking/create.tsA silent retry loop is how a week of confirmations goes missing.
Files
src/outbox/worker.tstests/outbox.test.tsNo account, no guessable identifiers.
Files
src/links.tstests/links.test.ts
done when · tick each as it passeswatch out- Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones.
What the page does when the calendar is downproduct builder
Stale information stops the page rather than producing a booking on top of a real meeting.
The graceful fallback is the one that double-books you.
Files
src/calendar/freshness.tstests/stale.test.tsThese are the two failures that are invisible until a client complains.
Files
src/views/ops.ejsSomebody will ask what happened to Thursday.
Files
src/booking/cancel.tssrc/export.ts
done when · tick each as it passeswatch out- Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it.
That is the whole plan for Book Like A Boss. 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 time.
- Text message reminders.
- Hosting other people's calendars.
- reliable reminders and timezone edge cases
- multi-calendar conflict handling
- race-free booking under load
- team routing and round robin
- Intake questions attached to a booking type, stored as answers rather than as a blob of form fields
- A second calendar as a read-only busy source, which is mostly a loop over the same fetch
- An availability preview that shows the next two weeks exactly as a booker would see them
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.