Build Setmore
KINDAreplaces $12/mosaves $144/yrback to the verdict
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.
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 booking page and the sync worker.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - 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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_PATH | required | ./data/bookings.db | Holds, bookings, reminders and busy intervals. Back this up daily. |
CALENDAR_CLIENT_ID | required | ...apps.googleusercontent.com | The OAuth client for the calendar provider. |
CALENDAR_CLIENT_SECRETsecret | required | ...redacted | Pairs with the client id. Never in the repository. |
CALENDAR_REFRESH_TOKENsecret | required | 1//...redacted | Obtained once through consent. When this dies the page must offer no slots. |
EMAIL_API_KEYsecret | required | re_...redacted | Confirmations and reminders. |
OWNER_TIMEZONE | required | Europe/London | The zone your availability rules are written in. Never an offset. |
HOLD_MINUTES | required | 10 | How long an open booking form reserves a slot before it expires. |
ADMIN_PASSWORDsecret | required | a long random string | Guards the private page. The booking page itself is public by design. |
The build, in order
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.
Nine to five is not a time until you say where.
Files
src/lib/rules.tssrc/db/migrations/001_rules.sqlArithmetic on instants is what breaks in October.
Files
src/lib/slots.tstests/dst.test.tsThe cheapest fix for the most common support message.
Files
src/app/book/page.tsx
done when · tick each as it passeswatch 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.
The hold is the lock
Two simultaneous bookings for the same slot produce one confirmation and one clear refusal.
This one index is the entire concurrency design.
Files
src/db/migrations/002_holds.sqlTwo statements with a gap is how double bookings are made.
Files
src/lib/book.tstests/concurrency.test.tsAn abandoned form should not cost you a Tuesday morning.
Files
src/lib/expire.ts
done when · tick each as it passeswatch 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.
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.
You cannot leak a title you never fetched.
Files
src/lib/calendar/freebusy.tsThe provider documents this as a wipe and start again.
Files
src/lib/calendar/sync.tstests/sync-410.test.tsAn empty week is not the same as a free week.
Files
src/lib/availability.ts
done when · tick each as it passeswatch 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.
Confirmations and reminders that arrive once
A booking writes an event back to the calendar and sends exactly one confirmation and one reminder.
Without it, cancelling leaves a ghost in your calendar.
Files
src/lib/calendar/write.tsAt most once beats at least once when it is somebody's inbox.
Files
src/lib/reminders.tstests/reminders.test.tsHalf of scheduling support is people in the wrong hour.
Files
src/emails/confirmation.tsx
done when · tick each as it passeswatch 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.
Leaving, cancelling and getting the data outproduct builder
A visitor can cancel or reschedule from their own link, and you can take everything with you or delete it.
A sequential id is an invitation to cancel strangers' appointments.
Files
src/lib/tokens.tstests/token-tamper.test.tsA reschedule that fails halfway should not lose the appointment.
Files
src/lib/reschedule.tsSomebody will ask you to forget them and you still need your diary.
Files
src/cli/export.tssrc/cli/forget.ts
done when · tick each as it passeswatch out- Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment.
That is the whole plan for Setmore. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Team routing and round robin.
- Payments at booking.
- SMS reminders.
- Group classes and recurring appointments.
- 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 calendar read only for conflicts, which is one more busy source and no new model
- Question fields on the booking form, stored with the booking and included in the export
- A waiting list for a slot that frees up, which is genuinely useful and needs nothing beyond the holds table
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.