Build OnceHub

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

estimated effort multi-daythe files for this build are in the project pack

RuntimeNext.js with TypeScript, one public booking page and one private adminStoragePostgreSQL with an exclusion constraint on the booking rangeTimeWall time plus IANA zone on every future row, resolved at renderCalendarOne provider, read for busy time, write the event, reconcile on a scheduleMailA transactional provider for confirmations and reminders, with signed management links

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 service and the reminder worker.

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

    Verify node --version prints v22 or higher

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

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

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

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

VariableNeededExampleWhere the value comes from
DATABASE_URLrequiredpostgres://localhost:5432/bookingBookings, holds, availability rules and audit.
OWNER_ZONErequiredEurope/LondonThe zone your working hours are written in. Changing it changes every future slot.
LINK_SIGNING_SECRETsecretrequiredhex-64-chars-redactedSigns management links. Rotating it invalidates outstanding links, so plan for it.
CALENDAR_CLIENT_SECRETsecretrequiredredactedCalendar provider credentials. Least scopes that still return busy time.
BUSY_MAX_STALE_MINUTESrequired15Beyond this the page narrows availability and says so.
HOLD_EXPIRY_SECONDSrequired600How long a slot is held while a form is open.
REMINDER_LEAD_MINUTESrequired1440How far ahead reminders are scheduled.

The build, in order

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

    1. Adding hours to a UTC instant is what breaks in October.

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

    2. Detection is a guess and the guess is wrong for travellers.

      Files src/app/[slug]/page.tsx

    3. You cannot re-resolve safely without knowing what you resolved under.

      Files src/lib/tzdb.ts

    done when · tick each as it passes
    watch 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.
  2. Reserving a slot without a race

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

    1. It is the only referee both requests can hear.

      Files src/db/migrations/002_exclusion.sqltests/concurrent-booking.test.ts

    2. Long holds are a denial of service on your own calendar.

      Files src/lib/holds.ts

    3. Somebody lost the race and still wants a meeting.

      Files src/app/api/book/route.ts

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

    1. An empty busy list and a broken call look identical.

      Files src/lib/busy.tstests/busy-failclosed.test.ts

    2. Without it, reconciliation is guesswork over titles.

      Files src/lib/calendar.ts

    3. Someone deleted that event on purpose, possibly.

      Files src/jobs/reconcile.ts

    done when · tick each as it passes
    watch 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.
  4. Telling people, and letting them change their mind

    Confirmations, reminders and cancellations work through links that cannot be guessed or replayed.

    1. A sequential id in a URL is an invitation.

      Files src/lib/tokens.tstests/token-forgery.test.ts

    2. A restart between claim and send should cost nothing.

      Files src/jobs/reminders.ts

    3. The disputed booking is always one you did not touch.

      Files src/lib/audit.ts

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