Build Book Like A Boss

KINDAback to the verdict

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

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

RuntimeNode with TypeScript and server-rendered pages, progressive enhancement onlyDatabasePostgreSQL 16 or newer with btree_gist installedTimesInstants as timestamptz, rules as local time plus an IANA zone identifierCalendarFree and busy intervals in, events out with an explicit zone identifierMailOne provider, with every message written to an outbox table before the response returns

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 application and the outbox worker.

    Get it nodejs.org or your version manager. open ↗

    Verify node --version prints v22 or higher

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

  3. free

    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

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

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

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/bookingPostgreSQL with btree_gist installed. The constraint lives here.
RESOURCE_ZONErequiredEurope/LondonThe IANA zone your availability rules are written in. Not an offset.
CALENDAR_CLIENT_IDrequired1234-abcd.apps.googleusercontent.comOAuth client for free and busy access.
CALENDAR_CLIENT_SECRETsecretrequiredGOCSPX-...redactedPaired with the client id. Never in the repository.
BUSY_MAX_AGE_MINUTESrequired15Past this the busy cache counts as no data and the page stops offering slots.
MAIL_API_KEYsecretrequiredre_...redactedTransactional mail. The outbox worker is the only caller.
LINK_SIGNING_KEYsecretrequireda-long-random-string-redactedSigns cancellation and reschedule links. Rotating it invalidates outstanding links.
BOOKING_HORIZON_DAYSrequired60How far ahead the page will offer anything.

The build, in order

  1. Availability computed from rules, not stored as slots

    A week of slots is derived from local-time rules and survives a daylight saving change.

    1. An offset is only true until the next transition.

      Files src/db/schema.sqlsrc/availability/rules.ts

    2. The expansion is the part that has to be aware of the transition.

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

    3. Both sides of a booking should be able to see which clock it means.

      Files src/views/book.ejs

    done when · tick each as it passes
    watch 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.
  2. The database refuses the overlap

    Two people submitting the same slot at the same moment produce one booking and one clear refusal.

    1. This is the single most important line in the schema.

      Files src/db/schema.sqltests/concurrency.test.ts

    2. Otherwise back-to-back bookings leave you no walk between them.

      Files src/booking/create.ts

    3. The person is still there and still wants a time.

      Files src/booking/create.tssrc/views/taken.ejs

    done when · tick each as it passes
    watch 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.
  3. Busy in, events out, and nothing else

    The connected calendar contributes intervals only, and the booking appears in it correctly.

    1. The schema is the enforcement; the query is only the intent.

      Files src/calendar/freebusy.tssrc/db/schema.sql

    2. A floating time in someone else's calendar means a different hour.

      Files src/calendar/write.ts

    3. It 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 passes
    watch 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.
  4. 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.

    1. Either both happened or neither did.

      Files src/outbox/enqueue.tssrc/booking/create.ts

    2. A silent retry loop is how a week of confirmations goes missing.

      Files src/outbox/worker.tstests/outbox.test.ts

    3. No account, no guessable identifiers.

      Files src/links.tstests/links.test.ts

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