Build Setmore

KINDAreplaces $12/mosaves $144/yrback 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 one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token.

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

RuntimeNext.js 15 with TypeScript, the booking page public and everything else behind a passwordStorageSQLite with a unique index on the slot instant, which is the actual booking lockCalendarRead busy intervals through the provider's free and busy query, plus incremental sync for the working copyTimeInstants stored in UTC, rules stored with the IANA zone they were written inEmailOne transactional provider, with a reminders row claimed before sending

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 sync worker.

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

    Verify node --version prints v22 or higher

  2. API keyfree

    Why Every additional scope is more of your diary exposed to a tool you wrote at the weekend.

    Get it Create a project with your calendar provider, enable the calendar API, and request only the scopes you need to read free and busy and to create events.

    Verify A free and busy query returns intervals and no event content

  3. have readyfree to a few pounds a month

    Why The consent flow needs a fixed redirect and your visitors need a link that does not change.

    Get it A small host or a tunnel with a stable hostname.

    Verify The consent flow completes and returns to your address

  4. API keyfree tiers are usually enough at this volume

    Why Confirmations and reminders go out from here, and a personal mailbox is not the right sender for automated mail.

    Get it Any transactional provider with a verified sending domain.

    Verify A test confirmation arrives with correct times

  5. decidefree

    Why These are the input to everything in phase one, and deciding them in code means deciding them badly.

    Get it Write the weekday windows, slot length, buffers, lead time and horizon into a file before you start.

    Verify The rules file is complete and every rule names a zone

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state)
- busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title
- rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days)
- holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock
- bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at)
- reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together
- A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening.

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/bookings.dbHolds, bookings, reminders and busy intervals. Back this up daily.
CALENDAR_CLIENT_IDrequired...apps.googleusercontent.comThe OAuth client for the calendar provider.
CALENDAR_CLIENT_SECRETsecretrequired...redactedPairs with the client id. Never in the repository.
CALENDAR_REFRESH_TOKENsecretrequired1//...redactedObtained once through consent. When this dies the page must offer no slots.
EMAIL_API_KEYsecretrequiredre_...redactedConfirmations and reminders.
OWNER_TIMEZONErequiredEurope/LondonThe zone your availability rules are written in. Never an offset.
HOLD_MINUTESrequired10How long an open booking form reserves a slot before it expires.
ADMIN_PASSWORDsecretrequireda long random stringGuards the private page. The booking page itself is public by design.

The build, in order

  1. Slots that mean what the clock says

    Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change.

    1. Nine to five is not a time until you say where.

      Files src/lib/rules.tssrc/db/migrations/001_rules.sql

    2. Arithmetic on instants is what breaks in October.

      Files src/lib/slots.tstests/dst.test.ts

    3. The cheapest fix for the most common support message.

      Files src/app/book/page.tsx

    done when · tick each as it passes
    watch out
    • Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually.
  2. The hold is the lock

    Two simultaneous bookings for the same slot produce one confirmation and one clear refusal.

    1. This one index is the entire concurrency design.

      Files src/db/migrations/002_holds.sql

    2. Two statements with a gap is how double bookings are made.

      Files src/lib/book.tstests/concurrency.test.ts

    3. An abandoned form should not cost you a Tuesday morning.

      Files src/lib/expire.ts

    done when · tick each as it passes
    watch out
    • Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error.
  3. Reading the diary without reading the diary

    Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected.

    1. You cannot leak a title you never fetched.

      Files src/lib/calendar/freebusy.ts

    2. The provider documents this as a wipe and start again.

      Files src/lib/calendar/sync.tstests/sync-410.test.ts

    3. An empty week is not the same as a free week.

      Files src/lib/availability.ts

    done when · tick each as it passes
    watch out
    • Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch.
  4. Confirmations and reminders that arrive once

    A booking writes an event back to the calendar and sends exactly one confirmation and one reminder.

    1. Without it, cancelling leaves a ghost in your calendar.

      Files src/lib/calendar/write.ts

    2. At most once beats at least once when it is somebody's inbox.

      Files src/lib/reminders.tstests/reminders.test.ts

    3. Half of scheduling support is people in the wrong hour.

      Files src/emails/confirmation.tsx

    done when · tick each as it passes
    watch out
    • A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly.
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.