Build OnceHub
KINDAreplaces $12/mosaves $144/yrback to the verdict
A one-person booking service whose hard problems are surviving a time zone rule change between the booking and the meeting, reserving a slot under concurrency without a read-then-write race, and treating an external calendar as an input you reconcile rather than a source of truth you trust.
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 service and the reminder worker.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why The exclusion constraint that prevents double booking is the one piece of this build a database has to provide.
Get it A local install or a managed instance.
Verify
The exclusion constraint migration applies and rejects an overlapping insert - API keyfree for personal use
Why Busy time is the only input that stops you offering slots you are not actually free for.
Get it Create an application in the provider's console, request the minimum scopes and complete any verification.
Verify
A busy read for the next fortnight returns intervals you recognise - API keyfree tiers are usually enough for one person's bookings
Why Confirmations and reminders are the difference between a booking and a hope, and a personal mailbox is not a sender.
Get it Any transactional provider with a verified sending domain.
Verify
A confirmation reaches an external inbox and not a spam folder - decidefree
Why The re-resolution step is the one thing here that cannot be retrofitted, because by then the wrong instants are already in people's calendars.
Get it Decide who reviews the report, how corrections are worded and how long after a tz upgrade the check must run.
Verify
The decision is written in the README and the command exists
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- event_types(id, slug, duration_minutes, buffer_before, buffer_after, min_notice_minutes, horizon_days, active) - availability_rules(id, event_type_id, weekday, start_local, end_local, owner_zone) · the owner's working hours in the owner's zone - bookings(id, event_type_id, local_start, local_end, iana_zone, tzdb_version, instant_start, instant_end, state, booker_name, booker_email, created_at, cancelled_at) - holds(id, event_type_id, instant_start, instant_end, expires_at, session_token) · a slot reserved while the form is being filled in - calendar_links(id, provider, account, connected_at, last_reconciled_at, last_error) · one row, and its age is a health number - notifications(id, booking_id, kind, scheduled_for, sent_at, provider_id, failure_reason) - audit(id, at, actor, action, booking_id, before_json, after_json) · every state change, including the ones the system made - instant_start is a derived cache, not the truth. The truth is local_start plus iana_zone, and a tz database upgrade recomputes the cache.
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_URL | required | postgres://localhost:5432/booking | Bookings, holds, availability rules and audit. |
OWNER_ZONE | required | Europe/London | The zone your working hours are written in. Changing it changes every future slot. |
LINK_SIGNING_SECRETsecret | required | hex-64-chars-redacted | Signs management links. Rotating it invalidates outstanding links, so plan for it. |
CALENDAR_CLIENT_SECRETsecret | required | redacted | Calendar provider credentials. Least scopes that still return busy time. |
BUSY_MAX_STALE_MINUTES | required | 15 | Beyond this the page narrows availability and says so. |
HOLD_EXPIRY_SECONDS | required | 600 | How long a slot is held while a form is open. |
REMINDER_LEAD_MINUTES | required | 1440 | How far ahead reminders are scheduled. |
The build, in order
Availability from rules, in a named zone
A booking page shows genuine open slots for a visitor in their own zone, computed from your rules.
Adding hours to a UTC instant is what breaks in October.
Files
src/lib/availability.tstests/availability.dst.test.tsDetection is a guess and the guess is wrong for travellers.
Files
src/app/[slug]/page.tsxYou cannot re-resolve safely without knowing what you resolved under.
Files
src/lib/tzdb.ts
done when · tick each as it passeswatch out- Generating slots by adding a fixed number of hours to a UTC midnight produces a working day that shifts by an hour twice a year, so half your autumn mornings open at eight and half at nine and nobody can explain it. Generate in the owner's local wall time, then resolve each candidate to an instant through the zone, which is the only order that survives a transition.
Reserving a slot without a race
Two people submitting the same slot at the same moment produce one booking and one clear refusal.
It is the only referee both requests can hear.
Files
src/db/migrations/002_exclusion.sqltests/concurrent-booking.test.tsLong holds are a denial of service on your own calendar.
Files
src/lib/holds.tsSomebody lost the race and still wants a meeting.
Files
src/app/api/book/route.ts
done when · tick each as it passeswatch out- Checking availability and then inserting is two statements with a gap, and the gap is exactly long enough for a second booker on a slow connection, which is why double bookings cluster on popular slots rather than random ones. Push the check into a constraint the database enforces and treat its violation as the normal path, not an exception to log.
The calendar is an input you reconcile
External busy time narrows availability, the event gets written, and drift between the two is detected rather than assumed away.
An empty busy list and a broken call look identical.
Files
src/lib/busy.tstests/busy-failclosed.test.tsWithout it, reconciliation is guesswork over titles.
Files
src/lib/calendar.tsSomeone deleted that event on purpose, possibly.
Files
src/jobs/reconcile.ts
done when · tick each as it passeswatch out- Treating the calendar as authoritative means one failed network call opens your entire week to bookings, because an empty busy list looks exactly like a free week. Fail closed: if the busy read is older than the configured staleness or errors, narrow availability rather than widen it, and say on the page that availability is limited right now.
Telling people, and letting them change their mind
Confirmations, reminders and cancellations work through links that cannot be guessed or replayed.
A sequential id in a URL is an invitation.
Files
src/lib/tokens.tstests/token-forgery.test.tsA restart between claim and send should cost nothing.
Files
src/jobs/reminders.tsThe disputed booking is always one you did not touch.
Files
src/lib/audit.ts
done when · tick each as it passeswatch out- A management link built from a sequential booking id lets anyone walk the range and cancel strangers' meetings, and you will find out when someone does. Sign a token bound to the booking id with an expiry, verify the signature before loading the row, and do not include the booking id in the visible path.
Surviving a time zone upgrade, and getting outproduct builder
A tz database update is a routine operation with a report, and everything exports.
A time that moved is news, not a migration side effect.
Files
src/cli/reresolve.tstests/reresolve.test.tsThe invitation in their calendar is the one they trust.
Files
src/jobs/corrections.tsThe slot happened; the person's address need not persist.
Files
src/lib/erasure.ts
done when · tick each as it passeswatch out- Recomputing instants during a deploy and moving on means a set of people receive a reminder for a time that no longer matches their calendar invitation, and you learn about it from the person who missed the meeting. Make re-resolution a command that produces a list for a human, and send corrections deliberately with an explanation of why the time changed.
That is the whole plan for OnceHub. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Round-robin and team routing.
- Payments at booking time.
- A second calendar provider.
- Branching routing forms.
- routing depth, calendar integrations, CRM workflows, and dependable scheduling
- multiple calendar providers
- team routing and round-robin
- payments
- timezone edge-case maturity
- A read-only public page showing the next fortnight, which removes most of the back and forth before anyone books
- A tz database upgrade check in the deployment pipeline, so the report runs before anyone notices
- An availability preview from a chosen zone, which is how you catch a rule mistake before a visitor does
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.