Vibecode OnceHub
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For OnceHub, publish booking pages with routing rules, forms, and reminders. The hard boundary is routing depth, calendar integrations, crm workflows, and dependable scheduling, plus calendar integrations and reliability.
You are building a lean indie version of OnceHub. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # OnceHub · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, one public booking page and one private admin | the public page is the product and it has to be fast and boring | | Storage | PostgreSQL with an exclusion constraint on the booking range | the database is the only thing that can settle a race between two bookers | | Time | Wall time plus IANA zone on every future row, resolved at render | offsets change after the booking is taken and a stored instant cannot know | | Calendar | One provider, read for busy time, write the event, reconcile on a schedule | a calendar is an input, and treating it as the source of truth means trusting a network call | | Mail | A transactional provider for confirmations and reminders, with signed management links | a booking nobody was told about is not a booking | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking service and the reminder worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with range types** · free 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 - [ ] **Calendar provider credentials for one account** · free 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 - [ ] **A transactional mail sender** · free 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 - [ ] **What happens when a zone rule changes** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for OnceHub, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · OnceHub The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-person booking page that stores meetings as wall time in a named zone and can prove it never double-booked to replace OnceHub. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the reason this build takes longer than it looks. Never store a future booking only as an instant. Time zone rules are political and they change after you have taken the booking. The current example is concrete: tz database release 2026d, announced in September 2026, records that Canada's Northwest Territories will not fall back on 2026-11-01 and will stay on minus six year round, which moves America/Inuvik for every date after that. A meeting booked in July for a December afternoon in Inuvik, converted to a UTC instant under the old rules and stored as that instant, is now an hour wrong, and no column in your database will say so. Store the local wall time and the IANA zone identifier the person chose, record the tz database version the row was created under, and compute the instant when you render or send. After a tz database upgrade, re-resolve every future booking and report the ones that moved rather than silently emailing a new time. Second: a double booking is the failure that ends the tool, because the cost lands on someone else's morning. Availability is not a read followed by a write. Reserve inside one transaction with a constraint the database enforces, so two simultaneous bookers cannot both win, and test it with two concurrent requests rather than reasoning about it. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, one public booking page and one private admin · the public page is the product and it has to be fast and boring - Storage: PostgreSQL with an exclusion constraint on the booking range · the database is the only thing that can settle a race between two bookers - Time: Wall time plus IANA zone on every future row, resolved at render · offsets change after the booking is taken and a stored instant cannot know - Calendar: One provider, read for busy time, write the event, reconcile on a schedule · a calendar is an input, and treating it as the source of truth means trusting a network call - Mail: A transactional provider for confirmations and reminders, with signed management links · a booking nobody was told about is not a booking ### Data model (create this before Phase 1) - 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. ### Phase 1 · Availability from rules, in a named zone Build: Event types with duration, buffers, minimum notice and a booking horizon. Working hours stored as local times in the owner's zone. Generate candidate slots for the horizon, subtract buffers and minimum notice, and render them in the visitor's detected zone with the zone name shown and changeable. Store the tz database version the server is running. Trap: 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. Done when: Slot generation across a daylight saving transition produces the same local working hours on both sides, tested with a fixture date either side of a real transition. The visitor's zone is displayed by name and can be changed, and changing it re-renders the same slots at different local times. Minimum notice and horizon are enforced server side, tested by requesting a slot outside both. The running tz database version is recorded and shown on an admin page. Do not build yet: No bookings, no holds, no calendar, no email. ### Phase 2 · Reserving a slot without a race Build: A hold created when the booking form opens, with a short expiry. On submit, insert the booking inside a transaction protected by a range exclusion constraint covering the slot plus its buffers. Let the database refuse the second writer and turn that refusal into a readable message with the next available slots offered. Trap: 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. Done when: Two concurrent submissions for the same slot produce exactly one booking and one refusal, tested with parallel requests against a real database. The refusal offers alternative slots rather than a stack trace. An expired hold frees its slot automatically and the expiry is tested at the boundary. Buffers are included in the constraint, so a booking cannot start inside the previous meeting's buffer, tested. Do not build yet: No calendar writing, no email, no cancellation. ### Phase 3 · The calendar is an input you reconcile Build: Connect one calendar provider. Read busy intervals for the horizon and subtract them from generated slots, with a cache and a stated maximum staleness. Write an event when a booking is confirmed and store its external id. Run a reconciliation pass that compares your bookings against the calendar and reports differences instead of correcting them silently. Trap: 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. Done when: A failed or stale busy read narrows availability and shows a notice, tested by pointing the client at a dead endpoint. A confirmed booking creates an event and stores its external id, and deleting that event externally is reported by reconciliation rather than silently recreated. The reconciliation pass records its own last run time and an admin page shows the age. Token expiry produces one clear reconnect prompt, not a failure on every page load. Do not build yet: No email, no cancellation links, no tz upgrade handling. ### Phase 4 · Telling people, and letting them change their mind Build: Send a confirmation on booking, a reminder at a configured lead time, and a cancellation notice. Management links carry a signed token scoped to one booking with an expiry. Reminders are scheduled rows, claimed by a worker with a conditional update so a restart cannot send twice. Trap: 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. Done when: A management link for one booking cannot be edited into a valid link for another, tested by altering the token. Reminder sends are idempotent across a worker restart mid-send, tested by killing the worker between claim and send. A cancellation updates the booking, removes the calendar event and sends one notice, with an audit row for each step. Mail failures are retried with backoff and surfaced on an admin page rather than lost. Do not build yet: No tz upgrade handling, no export, no operational documentation. ### Phase 5 · Surviving a time zone upgrade, and getting out Build: A command that re-resolves every future booking under the current tz database, compares against the stored cached instant, and prints the bookings whose instant moved along with who is affected. Nothing is emailed automatically. Export bookings, event types and audit rows as CSV, and delete a booker's details on request while keeping the slot and the audit trail. Trap: 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. Done when: Running the re-resolution command against a fixture with a changed zone rule lists exactly the affected bookings and changes nothing else, tested. No code path emails a corrected time without an explicit confirmation, verified by review and asserted in the suite. Export produces bookings, event types and audit rows as CSV and reimports into an empty database with the same counts. Deleting a booker's personal details keeps the slot, the audit trail and the totals intact, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Round-robin and team routing, which needs several calendars, a fairness rule and a shared availability model, and is a different product from one person's page. - Payments at booking time, since taking money adds refunds, disputes and a processor's rules to a tool whose job is to not double-book. - More than one calendar provider, because each one has its own permissions, quirks and reconnect behaviour and the second one doubles the maintenance. - Routing forms that branch to different event types, which is worth building only once the single path is provably conflict free. ### README must contain - That future bookings store a wall time and an IANA zone, not an instant, and exactly why - The tz database version the service is running and how to run the re-resolution command after an upgrade - That conflict prevention lives in a database constraint, with the migration that creates it named - That availability fails closed when calendar data is stale, and where the staleness threshold is set - How management links are signed and scoped, and what to do if the signing secret is rotated ===== AGENTS.md ===== # Agent instructions · OnceHub indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, one public booking page and one private admin, PostgreSQL with an exclusion constraint on the booking range, Wall time plus IANA zone on every future row, resolved at render, One provider, read for busy time, write the event, reconcile on a schedule, A transactional provider for confirmations and reminders, with signed management links. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · OnceHub 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate candidates in the owner's wall time Adding hours to a UTC instant is what breaks in October. Files: `src/lib/availability.ts`, `tests/availability.dst.test.ts` 2. Show the visitor's zone by name and let them change it Detection is a guess and the guess is wrong for travellers. Files: `src/app/[slug]/page.tsx` 3. Record the tz database version the server runs You cannot re-resolve safely without knowing what you resolved under. Files: `src/lib/tzdb.ts` ### Done when - [ ] Working hours are stable across a transition - [ ] Visitor zone is named and changeable - [ ] Notice and horizon are enforced server side - [ ] The tz database version is recorded ### 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. ## Phase 2 · Reserving a slot without a race Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the conflict rule in a database constraint It is the only referee both requests can hear. Files: `src/db/migrations/002_exclusion.sql`, `tests/concurrent-booking.test.ts` 2. Hold the slot while the form is open, briefly Long holds are a denial of service on your own calendar. Files: `src/lib/holds.ts` 3. Turn a constraint violation into an offer Somebody lost the race and still wants a meeting. Files: `src/app/api/book/route.ts` ### Done when - [ ] Concurrent submissions yield one booking - [ ] A refusal offers alternatives - [ ] Holds expire and free the slot - [ ] Buffers participate in the constraint ### 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. ## Phase 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. ### Steps 1. Fail closed when busy data is stale or missing An empty busy list and a broken call look identical. Files: `src/lib/busy.ts`, `tests/busy-failclosed.test.ts` 2. Store the external event id with the booking Without it, reconciliation is guesswork over titles. Files: `src/lib/calendar.ts` 3. Report drift, do not silently repair it Someone deleted that event on purpose, possibly. Files: `src/jobs/reconcile.ts` ### Done when - [ ] Stale busy data narrows availability - [ ] Bookings store their external event id - [ ] Reconciliation reports rather than repairs - [ ] Reconnect is prompted once, not per page ### 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. ## Phase 4 · Telling people, and letting them change their mind Confirmations, reminders and cancellations work through links that cannot be guessed or replayed. ### Steps 1. Sign management tokens, scope them, expire them A sequential id in a URL is an invitation. Files: `src/lib/tokens.ts`, `tests/token-forgery.test.ts` 2. Claim reminders before sending them A restart between claim and send should cost nothing. Files: `src/jobs/reminders.ts` 3. Audit every state change, including automatic ones The disputed booking is always one you did not touch. Files: `src/lib/audit.ts` ### Done when - [ ] A forged management token is refused - [ ] Reminders survive a restart without duplicates - [ ] Cancellation updates booking, calendar and mail - [ ] Mail failures are visible and retried ### 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. ## Not in this build - Round-robin and team routing. - Payments at booking time. - A second calendar provider. - Branching routing forms. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds, availability rules and audit. DATABASE_URL=postgres://localhost:5432/booking # Required. The zone your working hours are written in. Changing it changes every future slot. OWNER_ZONE=Europe/London # Required · secret. Signs management links. Rotating it invalidates outstanding links, so plan for it. LINK_SIGNING_SECRET=hex-64-chars-redacted # Required · secret. Calendar provider credentials. Least scopes that still return busy time. CALENDAR_CLIENT_SECRET=redacted # Required. Beyond this the page narrows availability and says so. BUSY_MAX_STALE_MINUTES=15 # Required. How long a slot is held while a form is open. HOLD_EXPIRY_SECONDS=600 # Required. How far ahead reminders are scheduled. REMINDER_LEAD_MINUTES=1440
You are building a lean indie version of OnceHub. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # OnceHub · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, one public booking page and one private admin | the public page is the product and it has to be fast and boring | | Storage | PostgreSQL with an exclusion constraint on the booking range | the database is the only thing that can settle a race between two bookers | | Time | Wall time plus IANA zone on every future row, resolved at render | offsets change after the booking is taken and a stored instant cannot know | | Calendar | One provider, read for busy time, write the event, reconcile on a schedule | a calendar is an input, and treating it as the source of truth means trusting a network call | | Mail | A transactional provider for confirmations and reminders, with signed management links | a booking nobody was told about is not a booking | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking service and the reminder worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with range types** · free 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 - [ ] **Calendar provider credentials for one account** · free 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 - [ ] **A transactional mail sender** · free 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 - [ ] **What happens when a zone rule changes** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for OnceHub, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · OnceHub The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-person booking page that stores meetings as wall time in a named zone and can prove it never double-booked to replace OnceHub. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the reason this build takes longer than it looks. Never store a future booking only as an instant. Time zone rules are political and they change after you have taken the booking. The current example is concrete: tz database release 2026d, announced in September 2026, records that Canada's Northwest Territories will not fall back on 2026-11-01 and will stay on minus six year round, which moves America/Inuvik for every date after that. A meeting booked in July for a December afternoon in Inuvik, converted to a UTC instant under the old rules and stored as that instant, is now an hour wrong, and no column in your database will say so. Store the local wall time and the IANA zone identifier the person chose, record the tz database version the row was created under, and compute the instant when you render or send. After a tz database upgrade, re-resolve every future booking and report the ones that moved rather than silently emailing a new time. Second: a double booking is the failure that ends the tool, because the cost lands on someone else's morning. Availability is not a read followed by a write. Reserve inside one transaction with a constraint the database enforces, so two simultaneous bookers cannot both win, and test it with two concurrent requests rather than reasoning about it. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, one public booking page and one private admin · the public page is the product and it has to be fast and boring - Storage: PostgreSQL with an exclusion constraint on the booking range · the database is the only thing that can settle a race between two bookers - Time: Wall time plus IANA zone on every future row, resolved at render · offsets change after the booking is taken and a stored instant cannot know - Calendar: One provider, read for busy time, write the event, reconcile on a schedule · a calendar is an input, and treating it as the source of truth means trusting a network call - Mail: A transactional provider for confirmations and reminders, with signed management links · a booking nobody was told about is not a booking ### Data model (create this before Phase 1) - 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. ### Phase 1 · Availability from rules, in a named zone Build: Event types with duration, buffers, minimum notice and a booking horizon. Working hours stored as local times in the owner's zone. Generate candidate slots for the horizon, subtract buffers and minimum notice, and render them in the visitor's detected zone with the zone name shown and changeable. Store the tz database version the server is running. Trap: 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. Done when: Slot generation across a daylight saving transition produces the same local working hours on both sides, tested with a fixture date either side of a real transition. The visitor's zone is displayed by name and can be changed, and changing it re-renders the same slots at different local times. Minimum notice and horizon are enforced server side, tested by requesting a slot outside both. The running tz database version is recorded and shown on an admin page. Do not build yet: No bookings, no holds, no calendar, no email. ### Phase 2 · Reserving a slot without a race Build: A hold created when the booking form opens, with a short expiry. On submit, insert the booking inside a transaction protected by a range exclusion constraint covering the slot plus its buffers. Let the database refuse the second writer and turn that refusal into a readable message with the next available slots offered. Trap: 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. Done when: Two concurrent submissions for the same slot produce exactly one booking and one refusal, tested with parallel requests against a real database. The refusal offers alternative slots rather than a stack trace. An expired hold frees its slot automatically and the expiry is tested at the boundary. Buffers are included in the constraint, so a booking cannot start inside the previous meeting's buffer, tested. Do not build yet: No calendar writing, no email, no cancellation. ### Phase 3 · The calendar is an input you reconcile Build: Connect one calendar provider. Read busy intervals for the horizon and subtract them from generated slots, with a cache and a stated maximum staleness. Write an event when a booking is confirmed and store its external id. Run a reconciliation pass that compares your bookings against the calendar and reports differences instead of correcting them silently. Trap: 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. Done when: A failed or stale busy read narrows availability and shows a notice, tested by pointing the client at a dead endpoint. A confirmed booking creates an event and stores its external id, and deleting that event externally is reported by reconciliation rather than silently recreated. The reconciliation pass records its own last run time and an admin page shows the age. Token expiry produces one clear reconnect prompt, not a failure on every page load. Do not build yet: No email, no cancellation links, no tz upgrade handling. ### Phase 4 · Telling people, and letting them change their mind Build: Send a confirmation on booking, a reminder at a configured lead time, and a cancellation notice. Management links carry a signed token scoped to one booking with an expiry. Reminders are scheduled rows, claimed by a worker with a conditional update so a restart cannot send twice. Trap: 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. Done when: A management link for one booking cannot be edited into a valid link for another, tested by altering the token. Reminder sends are idempotent across a worker restart mid-send, tested by killing the worker between claim and send. A cancellation updates the booking, removes the calendar event and sends one notice, with an audit row for each step. Mail failures are retried with backoff and surfaced on an admin page rather than lost. Do not build yet: No tz upgrade handling, no export, no operational documentation. ### Phase 5 · Surviving a time zone upgrade, and getting out Build: A command that re-resolves every future booking under the current tz database, compares against the stored cached instant, and prints the bookings whose instant moved along with who is affected. Nothing is emailed automatically. Export bookings, event types and audit rows as CSV, and delete a booker's details on request while keeping the slot and the audit trail. Trap: 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. Done when: Running the re-resolution command against a fixture with a changed zone rule lists exactly the affected bookings and changes nothing else, tested. No code path emails a corrected time without an explicit confirmation, verified by review and asserted in the suite. Export produces bookings, event types and audit rows as CSV and reimports into an empty database with the same counts. Deleting a booker's personal details keeps the slot, the audit trail and the totals intact, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Round-robin and team routing, which needs several calendars, a fairness rule and a shared availability model, and is a different product from one person's page. - Payments at booking time, since taking money adds refunds, disputes and a processor's rules to a tool whose job is to not double-book. - More than one calendar provider, because each one has its own permissions, quirks and reconnect behaviour and the second one doubles the maintenance. - Routing forms that branch to different event types, which is worth building only once the single path is provably conflict free. ### README must contain - That future bookings store a wall time and an IANA zone, not an instant, and exactly why - The tz database version the service is running and how to run the re-resolution command after an upgrade - That conflict prevention lives in a database constraint, with the migration that creates it named - That availability fails closed when calendar data is stale, and where the staleness threshold is set - How management links are signed and scoped, and what to do if the signing secret is rotated ===== AGENTS.md ===== # Agent instructions · OnceHub indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, one public booking page and one private admin, PostgreSQL with an exclusion constraint on the booking range, Wall time plus IANA zone on every future row, resolved at render, One provider, read for busy time, write the event, reconcile on a schedule, A transactional provider for confirmations and reminders, with signed management links. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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. ===== BUILD_PLAN.md ===== # Build plan · OnceHub 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate candidates in the owner's wall time Adding hours to a UTC instant is what breaks in October. Files: `src/lib/availability.ts`, `tests/availability.dst.test.ts` 2. Show the visitor's zone by name and let them change it Detection is a guess and the guess is wrong for travellers. Files: `src/app/[slug]/page.tsx` 3. Record the tz database version the server runs You cannot re-resolve safely without knowing what you resolved under. Files: `src/lib/tzdb.ts` ### Done when - [ ] Working hours are stable across a transition - [ ] Visitor zone is named and changeable - [ ] Notice and horizon are enforced server side - [ ] The tz database version is recorded ### 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. ## Phase 2 · Reserving a slot without a race Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the conflict rule in a database constraint It is the only referee both requests can hear. Files: `src/db/migrations/002_exclusion.sql`, `tests/concurrent-booking.test.ts` 2. Hold the slot while the form is open, briefly Long holds are a denial of service on your own calendar. Files: `src/lib/holds.ts` 3. Turn a constraint violation into an offer Somebody lost the race and still wants a meeting. Files: `src/app/api/book/route.ts` ### Done when - [ ] Concurrent submissions yield one booking - [ ] A refusal offers alternatives - [ ] Holds expire and free the slot - [ ] Buffers participate in the constraint ### 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. ## Phase 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. ### Steps 1. Fail closed when busy data is stale or missing An empty busy list and a broken call look identical. Files: `src/lib/busy.ts`, `tests/busy-failclosed.test.ts` 2. Store the external event id with the booking Without it, reconciliation is guesswork over titles. Files: `src/lib/calendar.ts` 3. Report drift, do not silently repair it Someone deleted that event on purpose, possibly. Files: `src/jobs/reconcile.ts` ### Done when - [ ] Stale busy data narrows availability - [ ] Bookings store their external event id - [ ] Reconciliation reports rather than repairs - [ ] Reconnect is prompted once, not per page ### 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. ## Phase 4 · Telling people, and letting them change their mind Confirmations, reminders and cancellations work through links that cannot be guessed or replayed. ### Steps 1. Sign management tokens, scope them, expire them A sequential id in a URL is an invitation. Files: `src/lib/tokens.ts`, `tests/token-forgery.test.ts` 2. Claim reminders before sending them A restart between claim and send should cost nothing. Files: `src/jobs/reminders.ts` 3. Audit every state change, including automatic ones The disputed booking is always one you did not touch. Files: `src/lib/audit.ts` ### Done when - [ ] A forged management token is refused - [ ] Reminders survive a restart without duplicates - [ ] Cancellation updates booking, calendar and mail - [ ] Mail failures are visible and retried ### 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. ## Not in this build - Round-robin and team routing. - Payments at booking time. - A second calendar provider. - Branching routing forms. ## After v1, if you want it - 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 ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds, availability rules and audit. DATABASE_URL=postgres://localhost:5432/booking # Required. The zone your working hours are written in. Changing it changes every future slot. OWNER_ZONE=Europe/London # Required · secret. Signs management links. Rotating it invalidates outstanding links, so plan for it. LINK_SIGNING_SECRET=hex-64-chars-redacted # Required · secret. Calendar provider credentials. Least scopes that still return busy time. CALENDAR_CLIENT_SECRET=redacted # Required. Beyond this the page narrows availability and says so. BUSY_MAX_STALE_MINUTES=15 # Required. How long a slot is held while a form is open. HOLD_EXPIRY_SECONDS=600 # Required. How far ahead reminders are scheduled. REMINDER_LEAD_MINUTES=1440
You are building a production product version of OnceHub. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # OnceHub · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For OnceHub, publish booking pages with routing rules, forms, and reminders. The hard boundary is routing depth, calendar integrations, crm workflows, and dependable scheduling, plus calendar integrations and reliability. ## Product outcome A booking page that cannot double-book because a database constraint says so, that still holds the right time after a political time zone change, and that fails closed when it cannot see your calendar. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - 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 ## Success criteria - Concurrent submissions are confirmed to produce one booking - Working hours are confirmed stable across a daylight saving transition - A dead calendar endpoint is confirmed to narrow availability - A forged management token is confirmed refused - Re-resolution is confirmed to report without sending anything ===== BRIEF.md ===== # Build brief · OnceHub The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-person booking page that stores meetings as wall time in a named zone and can prove it never double-booked to replace OnceHub. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the reason this build takes longer than it looks. Never store a future booking only as an instant. Time zone rules are political and they change after you have taken the booking. The current example is concrete: tz database release 2026d, announced in September 2026, records that Canada's Northwest Territories will not fall back on 2026-11-01 and will stay on minus six year round, which moves America/Inuvik for every date after that. A meeting booked in July for a December afternoon in Inuvik, converted to a UTC instant under the old rules and stored as that instant, is now an hour wrong, and no column in your database will say so. Store the local wall time and the IANA zone identifier the person chose, record the tz database version the row was created under, and compute the instant when you render or send. After a tz database upgrade, re-resolve every future booking and report the ones that moved rather than silently emailing a new time. Second: a double booking is the failure that ends the tool, because the cost lands on someone else's morning. Availability is not a read followed by a write. Reserve inside one transaction with a constraint the database enforces, so two simultaneous bookers cannot both win, and test it with two concurrent requests rather than reasoning about it. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, one public booking page and one private admin · the public page is the product and it has to be fast and boring - Storage: PostgreSQL with an exclusion constraint on the booking range · the database is the only thing that can settle a race between two bookers - Time: Wall time plus IANA zone on every future row, resolved at render · offsets change after the booking is taken and a stored instant cannot know - Calendar: One provider, read for busy time, write the event, reconcile on a schedule · a calendar is an input, and treating it as the source of truth means trusting a network call - Mail: A transactional provider for confirmations and reminders, with signed management links · a booking nobody was told about is not a booking ### Data model (create this before Phase 1) - 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. ### Phase 1 · Availability from rules, in a named zone Build: Event types with duration, buffers, minimum notice and a booking horizon. Working hours stored as local times in the owner's zone. Generate candidate slots for the horizon, subtract buffers and minimum notice, and render them in the visitor's detected zone with the zone name shown and changeable. Store the tz database version the server is running. Trap: 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. Done when: Slot generation across a daylight saving transition produces the same local working hours on both sides, tested with a fixture date either side of a real transition. The visitor's zone is displayed by name and can be changed, and changing it re-renders the same slots at different local times. Minimum notice and horizon are enforced server side, tested by requesting a slot outside both. The running tz database version is recorded and shown on an admin page. Do not build yet: No bookings, no holds, no calendar, no email. ### Phase 2 · Reserving a slot without a race Build: A hold created when the booking form opens, with a short expiry. On submit, insert the booking inside a transaction protected by a range exclusion constraint covering the slot plus its buffers. Let the database refuse the second writer and turn that refusal into a readable message with the next available slots offered. Trap: 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. Done when: Two concurrent submissions for the same slot produce exactly one booking and one refusal, tested with parallel requests against a real database. The refusal offers alternative slots rather than a stack trace. An expired hold frees its slot automatically and the expiry is tested at the boundary. Buffers are included in the constraint, so a booking cannot start inside the previous meeting's buffer, tested. Do not build yet: No calendar writing, no email, no cancellation. ### Phase 3 · The calendar is an input you reconcile Build: Connect one calendar provider. Read busy intervals for the horizon and subtract them from generated slots, with a cache and a stated maximum staleness. Write an event when a booking is confirmed and store its external id. Run a reconciliation pass that compares your bookings against the calendar and reports differences instead of correcting them silently. Trap: 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. Done when: A failed or stale busy read narrows availability and shows a notice, tested by pointing the client at a dead endpoint. A confirmed booking creates an event and stores its external id, and deleting that event externally is reported by reconciliation rather than silently recreated. The reconciliation pass records its own last run time and an admin page shows the age. Token expiry produces one clear reconnect prompt, not a failure on every page load. Do not build yet: No email, no cancellation links, no tz upgrade handling. ### Phase 4 · Telling people, and letting them change their mind Build: Send a confirmation on booking, a reminder at a configured lead time, and a cancellation notice. Management links carry a signed token scoped to one booking with an expiry. Reminders are scheduled rows, claimed by a worker with a conditional update so a restart cannot send twice. Trap: 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. Done when: A management link for one booking cannot be edited into a valid link for another, tested by altering the token. Reminder sends are idempotent across a worker restart mid-send, tested by killing the worker between claim and send. A cancellation updates the booking, removes the calendar event and sends one notice, with an audit row for each step. Mail failures are retried with backoff and surfaced on an admin page rather than lost. Do not build yet: No tz upgrade handling, no export, no operational documentation. ### Phase 5 · Surviving a time zone upgrade, and getting out Build: A command that re-resolves every future booking under the current tz database, compares against the stored cached instant, and prints the bookings whose instant moved along with who is affected. Nothing is emailed automatically. Export bookings, event types and audit rows as CSV, and delete a booker's details on request while keeping the slot and the audit trail. Trap: 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. Done when: Running the re-resolution command against a fixture with a changed zone rule lists exactly the affected bookings and changes nothing else, tested. No code path emails a corrected time without an explicit confirmation, verified by review and asserted in the suite. Export produces bookings, event types and audit rows as CSV and reimports into an empty database with the same counts. Deleting a booker's personal details keeps the slot, the audit trail and the totals intact, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Round-robin and team routing, which needs several calendars, a fairness rule and a shared availability model, and is a different product from one person's page. - Payments at booking time, since taking money adds refunds, disputes and a processor's rules to a tool whose job is to not double-book. - More than one calendar provider, because each one has its own permissions, quirks and reconnect behaviour and the second one doubles the maintenance. - Routing forms that branch to different event types, which is worth building only once the single path is provably conflict free. ### README must contain - That future bookings store a wall time and an IANA zone, not an instant, and exactly why - The tz database version the service is running and how to run the re-resolution command after an upgrade - That conflict prevention lives in a database constraint, with the migration that creates it named - That availability fails closed when calendar data is stale, and where the staleness threshold is set - How management links are signed and scoped, and what to do if the signing secret is rotated ===== ARCHITECTURE.md ===== # Architecture · OnceHub ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, one public booking page and one private admin | the public page is the product and it has to be fast and boring | | Storage | PostgreSQL with an exclusion constraint on the booking range | the database is the only thing that can settle a race between two bookers | | Time | Wall time plus IANA zone on every future row, resolved at render | offsets change after the booking is taken and a stored instant cannot know | | Calendar | One provider, read for busy time, write the event, reconcile on a schedule | a calendar is an input, and treating it as the source of truth means trusting a network call | | Mail | A transactional provider for confirmations and reminders, with signed management links | a booking nobody was told about is not a booking | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Rules, generation in wall time and zone rendering. | Any generator, provided it works in local time and resolves afterwards. | | Reservation | Holds, the exclusion constraint and refusal handling. | Not swappable for a read-then-write check, whatever the framework suggests. | | Calendar | Busy reads, event writes and reconciliation. | Any provider, provided a failed read narrows rather than widens availability. | | Notify | Signed links, confirmations and idempotent reminders. | Any mail provider, provided sends are claimed before they are attempted. | | Timekeeping | tz database version, re-resolution and the change report. | Any implementation, provided nothing emails a corrected time on its own. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required · Bookings, holds, availability rules and audit. - `OWNER_ZONE` · required · The zone your working hours are written in. Changing it changes every future slot. - `LINK_SIGNING_SECRET` · required, secret · Signs management links. Rotating it invalidates outstanding links, so plan for it. - `CALENDAR_CLIENT_SECRET` · required, secret · Calendar provider credentials. Least scopes that still return busy time. - `BUSY_MAX_STALE_MINUTES` · required · Beyond this the page narrows availability and says so. - `HOLD_EXPIRY_SECONDS` · required · How long a slot is held while a form is open. - `REMINDER_LEAD_MINUTES` · required · How far ahead reminders are scheduled. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · OnceHub product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, one public booking page and one private admin, PostgreSQL with an exclusion constraint on the booking range, Wall time plus IANA zone on every future row, resolved at render, One provider, read for busy time, write the event, reconcile on a schedule, A transactional provider for confirmations and reminders, with signed management links. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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. ===== MILESTONES.md ===== # Delivery milestones · OnceHub Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Generate candidates in the owner's wall time Adding hours to a UTC instant is what breaks in October. Files: `src/lib/availability.ts`, `tests/availability.dst.test.ts` 2. Show the visitor's zone by name and let them change it Detection is a guess and the guess is wrong for travellers. Files: `src/app/[slug]/page.tsx` 3. Record the tz database version the server runs You cannot re-resolve safely without knowing what you resolved under. Files: `src/lib/tzdb.ts` ### Done when - [ ] Working hours are stable across a transition - [ ] Visitor zone is named and changeable - [ ] Notice and horizon are enforced server side - [ ] The tz database version is recorded ### 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. ## M2 · Reserving a slot without a race Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the conflict rule in a database constraint It is the only referee both requests can hear. Files: `src/db/migrations/002_exclusion.sql`, `tests/concurrent-booking.test.ts` 2. Hold the slot while the form is open, briefly Long holds are a denial of service on your own calendar. Files: `src/lib/holds.ts` 3. Turn a constraint violation into an offer Somebody lost the race and still wants a meeting. Files: `src/app/api/book/route.ts` ### Done when - [ ] Concurrent submissions yield one booking - [ ] A refusal offers alternatives - [ ] Holds expire and free the slot - [ ] Buffers participate in the constraint ### 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. ## M3 · 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. ### Steps 1. Fail closed when busy data is stale or missing An empty busy list and a broken call look identical. Files: `src/lib/busy.ts`, `tests/busy-failclosed.test.ts` 2. Store the external event id with the booking Without it, reconciliation is guesswork over titles. Files: `src/lib/calendar.ts` 3. Report drift, do not silently repair it Someone deleted that event on purpose, possibly. Files: `src/jobs/reconcile.ts` ### Done when - [ ] Stale busy data narrows availability - [ ] Bookings store their external event id - [ ] Reconciliation reports rather than repairs - [ ] Reconnect is prompted once, not per page ### 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. ## M4 · Telling people, and letting them change their mind Confirmations, reminders and cancellations work through links that cannot be guessed or replayed. ### Steps 1. Sign management tokens, scope them, expire them A sequential id in a URL is an invitation. Files: `src/lib/tokens.ts`, `tests/token-forgery.test.ts` 2. Claim reminders before sending them A restart between claim and send should cost nothing. Files: `src/jobs/reminders.ts` 3. Audit every state change, including automatic ones The disputed booking is always one you did not touch. Files: `src/lib/audit.ts` ### Done when - [ ] A forged management token is refused - [ ] Reminders survive a restart without duplicates - [ ] Cancellation updates booking, calendar and mail - [ ] Mail failures are visible and retried ### 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. ## M5 · Surviving a time zone upgrade, and getting out (production only) A tz database update is a routine operation with a report, and everything exports. ### Steps 1. Make re-resolution a command with a report A time that moved is news, not a migration side effect. Files: `src/cli/reresolve.ts`, `tests/reresolve.test.ts` 2. Require confirmation before any correction email The invitation in their calendar is the one they trust. Files: `src/jobs/corrections.ts` 3. Delete booker details without deleting the record The slot happened; the person's address need not persist. Files: `src/lib/erasure.ts` ### Done when - [ ] Re-resolution lists affected bookings only - [ ] No automatic correction email exists - [ ] Export and import round-trip every table - [ ] Erasure keeps the slot and the audit trail ### Watch 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. ===== OPERATIONS.md ===== # Operations · OnceHub ## Backup The database, nightly, and confirm the exclusion constraint exists in the restored schema. A restore that drops the constraint restores a service that will double-book on the first busy morning. ## Restore Restore, run the re-resolution command, and compare future booking counts and instants against the export. Then reconnect the calendar, because tokens do not survive an environment change. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Age of the last reconciliation, age of the busy cache, reminder failures, and the count of bookings whose re-resolved instant differs from the cache. The last number should be zero except in the days after a tz upgrade. ## Incident checklist A double booking and a wrong time are the two incidents, and both are quiet. The audit table plus the stored tz database version are what let you reconstruct what the visitor was shown and what was written, rather than arguing from memory. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Concurrent submissions are confirmed to produce one booking - [ ] Working hours are confirmed stable across a daylight saving transition - [ ] A dead calendar endpoint is confirmed to narrow availability - [ ] A forged management token is confirmed refused - [ ] Re-resolution is confirmed to report without sending anything ## Launch constraint Do not market omitted OnceHub capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds, availability rules and audit. DATABASE_URL=postgres://localhost:5432/booking # Required. The zone your working hours are written in. Changing it changes every future slot. OWNER_ZONE=Europe/London # Required · secret. Signs management links. Rotating it invalidates outstanding links, so plan for it. LINK_SIGNING_SECRET=hex-64-chars-redacted # Required · secret. Calendar provider credentials. Least scopes that still return busy time. CALENDAR_CLIENT_SECRET=redacted # Required. Beyond this the page narrows availability and says so. BUSY_MAX_STALE_MINUTES=15 # Required. How long a slot is held while a form is open. HOLD_EXPIRY_SECONDS=600 # Required. How far ahead reminders are scheduled. REMINDER_LEAD_MINUTES=1440
# OnceHub · indie build 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-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, one public booking page and one private admin | the public page is the product and it has to be fast and boring | | Storage | PostgreSQL with an exclusion constraint on the booking range | the database is the only thing that can settle a race between two bookers | | Time | Wall time plus IANA zone on every future row, resolved at render | offsets change after the booking is taken and a stored instant cannot know | | Calendar | One provider, read for busy time, write the event, reconcile on a schedule | a calendar is an input, and treating it as the source of truth means trusting a network call | | Mail | A transactional provider for confirmations and reminders, with signed management links | a booking nobody was told about is not a booking | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking service and the reminder worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with range types** · free 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 - [ ] **Calendar provider credentials for one account** · free 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 - [ ] **A transactional mail sender** · free 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 - [ ] **What happens when a zone rule changes** · free - 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 ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - 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 If one of those is essential to you, that is the reason to keep paying for OnceHub, and the README should say so rather than pretend.
# Build brief · OnceHub The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-person booking page that stores meetings as wall time in a named zone and can prove it never double-booked to replace OnceHub. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the reason this build takes longer than it looks. Never store a future booking only as an instant. Time zone rules are political and they change after you have taken the booking. The current example is concrete: tz database release 2026d, announced in September 2026, records that Canada's Northwest Territories will not fall back on 2026-11-01 and will stay on minus six year round, which moves America/Inuvik for every date after that. A meeting booked in July for a December afternoon in Inuvik, converted to a UTC instant under the old rules and stored as that instant, is now an hour wrong, and no column in your database will say so. Store the local wall time and the IANA zone identifier the person chose, record the tz database version the row was created under, and compute the instant when you render or send. After a tz database upgrade, re-resolve every future booking and report the ones that moved rather than silently emailing a new time. Second: a double booking is the failure that ends the tool, because the cost lands on someone else's morning. Availability is not a read followed by a write. Reserve inside one transaction with a constraint the database enforces, so two simultaneous bookers cannot both win, and test it with two concurrent requests rather than reasoning about it. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, one public booking page and one private admin · the public page is the product and it has to be fast and boring - Storage: PostgreSQL with an exclusion constraint on the booking range · the database is the only thing that can settle a race between two bookers - Time: Wall time plus IANA zone on every future row, resolved at render · offsets change after the booking is taken and a stored instant cannot know - Calendar: One provider, read for busy time, write the event, reconcile on a schedule · a calendar is an input, and treating it as the source of truth means trusting a network call - Mail: A transactional provider for confirmations and reminders, with signed management links · a booking nobody was told about is not a booking ### Data model (create this before Phase 1) - 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. ### Phase 1 · Availability from rules, in a named zone Build: Event types with duration, buffers, minimum notice and a booking horizon. Working hours stored as local times in the owner's zone. Generate candidate slots for the horizon, subtract buffers and minimum notice, and render them in the visitor's detected zone with the zone name shown and changeable. Store the tz database version the server is running. Trap: 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. Done when: Slot generation across a daylight saving transition produces the same local working hours on both sides, tested with a fixture date either side of a real transition. The visitor's zone is displayed by name and can be changed, and changing it re-renders the same slots at different local times. Minimum notice and horizon are enforced server side, tested by requesting a slot outside both. The running tz database version is recorded and shown on an admin page. Do not build yet: No bookings, no holds, no calendar, no email. ### Phase 2 · Reserving a slot without a race Build: A hold created when the booking form opens, with a short expiry. On submit, insert the booking inside a transaction protected by a range exclusion constraint covering the slot plus its buffers. Let the database refuse the second writer and turn that refusal into a readable message with the next available slots offered. Trap: 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. Done when: Two concurrent submissions for the same slot produce exactly one booking and one refusal, tested with parallel requests against a real database. The refusal offers alternative slots rather than a stack trace. An expired hold frees its slot automatically and the expiry is tested at the boundary. Buffers are included in the constraint, so a booking cannot start inside the previous meeting's buffer, tested. Do not build yet: No calendar writing, no email, no cancellation. ### Phase 3 · The calendar is an input you reconcile Build: Connect one calendar provider. Read busy intervals for the horizon and subtract them from generated slots, with a cache and a stated maximum staleness. Write an event when a booking is confirmed and store its external id. Run a reconciliation pass that compares your bookings against the calendar and reports differences instead of correcting them silently. Trap: 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. Done when: A failed or stale busy read narrows availability and shows a notice, tested by pointing the client at a dead endpoint. A confirmed booking creates an event and stores its external id, and deleting that event externally is reported by reconciliation rather than silently recreated. The reconciliation pass records its own last run time and an admin page shows the age. Token expiry produces one clear reconnect prompt, not a failure on every page load. Do not build yet: No email, no cancellation links, no tz upgrade handling. ### Phase 4 · Telling people, and letting them change their mind Build: Send a confirmation on booking, a reminder at a configured lead time, and a cancellation notice. Management links carry a signed token scoped to one booking with an expiry. Reminders are scheduled rows, claimed by a worker with a conditional update so a restart cannot send twice. Trap: 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. Done when: A management link for one booking cannot be edited into a valid link for another, tested by altering the token. Reminder sends are idempotent across a worker restart mid-send, tested by killing the worker between claim and send. A cancellation updates the booking, removes the calendar event and sends one notice, with an audit row for each step. Mail failures are retried with backoff and surfaced on an admin page rather than lost. Do not build yet: No tz upgrade handling, no export, no operational documentation. ### Phase 5 · Surviving a time zone upgrade, and getting out Build: A command that re-resolves every future booking under the current tz database, compares against the stored cached instant, and prints the bookings whose instant moved along with who is affected. Nothing is emailed automatically. Export bookings, event types and audit rows as CSV, and delete a booker's details on request while keeping the slot and the audit trail. Trap: 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. Done when: Running the re-resolution command against a fixture with a changed zone rule lists exactly the affected bookings and changes nothing else, tested. No code path emails a corrected time without an explicit confirmation, verified by review and asserted in the suite. Export produces bookings, event types and audit rows as CSV and reimports into an empty database with the same counts. Deleting a booker's personal details keeps the slot, the audit trail and the totals intact, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Round-robin and team routing, which needs several calendars, a fairness rule and a shared availability model, and is a different product from one person's page. - Payments at booking time, since taking money adds refunds, disputes and a processor's rules to a tool whose job is to not double-book. - More than one calendar provider, because each one has its own permissions, quirks and reconnect behaviour and the second one doubles the maintenance. - Routing forms that branch to different event types, which is worth building only once the single path is provably conflict free. ### README must contain - That future bookings store a wall time and an IANA zone, not an instant, and exactly why - The tz database version the service is running and how to run the re-resolution command after an upgrade - That conflict prevention lives in a database constraint, with the migration that creates it named - That availability fails closed when calendar data is stale, and where the staleness threshold is set - How management links are signed and scoped, and what to do if the signing secret is rotated
# Agent instructions · OnceHub indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with TypeScript, one public booking page and one private admin, PostgreSQL with an exclusion constraint on the booking range, Wall time plus IANA zone on every future row, resolved at render, One provider, read for busy time, write the event, reconcile on a schedule, A transactional provider for confirmations and reminders, with signed management links. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - 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. - 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. - 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. - 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.
# Build plan · OnceHub 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. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 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. ### Steps 1. Generate candidates in the owner's wall time Adding hours to a UTC instant is what breaks in October. Files: `src/lib/availability.ts`, `tests/availability.dst.test.ts` 2. Show the visitor's zone by name and let them change it Detection is a guess and the guess is wrong for travellers. Files: `src/app/[slug]/page.tsx` 3. Record the tz database version the server runs You cannot re-resolve safely without knowing what you resolved under. Files: `src/lib/tzdb.ts` ### Done when - [ ] Working hours are stable across a transition - [ ] Visitor zone is named and changeable - [ ] Notice and horizon are enforced server side - [ ] The tz database version is recorded ### 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. ## Phase 2 · Reserving a slot without a race Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the conflict rule in a database constraint It is the only referee both requests can hear. Files: `src/db/migrations/002_exclusion.sql`, `tests/concurrent-booking.test.ts` 2. Hold the slot while the form is open, briefly Long holds are a denial of service on your own calendar. Files: `src/lib/holds.ts` 3. Turn a constraint violation into an offer Somebody lost the race and still wants a meeting. Files: `src/app/api/book/route.ts` ### Done when - [ ] Concurrent submissions yield one booking - [ ] A refusal offers alternatives - [ ] Holds expire and free the slot - [ ] Buffers participate in the constraint ### 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. ## Phase 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. ### Steps 1. Fail closed when busy data is stale or missing An empty busy list and a broken call look identical. Files: `src/lib/busy.ts`, `tests/busy-failclosed.test.ts` 2. Store the external event id with the booking Without it, reconciliation is guesswork over titles. Files: `src/lib/calendar.ts` 3. Report drift, do not silently repair it Someone deleted that event on purpose, possibly. Files: `src/jobs/reconcile.ts` ### Done when - [ ] Stale busy data narrows availability - [ ] Bookings store their external event id - [ ] Reconciliation reports rather than repairs - [ ] Reconnect is prompted once, not per page ### 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. ## Phase 4 · Telling people, and letting them change their mind Confirmations, reminders and cancellations work through links that cannot be guessed or replayed. ### Steps 1. Sign management tokens, scope them, expire them A sequential id in a URL is an invitation. Files: `src/lib/tokens.ts`, `tests/token-forgery.test.ts` 2. Claim reminders before sending them A restart between claim and send should cost nothing. Files: `src/jobs/reminders.ts` 3. Audit every state change, including automatic ones The disputed booking is always one you did not touch. Files: `src/lib/audit.ts` ### Done when - [ ] A forged management token is refused - [ ] Reminders survive a restart without duplicates - [ ] Cancellation updates booking, calendar and mail - [ ] Mail failures are visible and retried ### 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. ## Not in this build - Round-robin and team routing. - Payments at booking time. - A second calendar provider. - Branching routing forms. ## After v1, if you want it - 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
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds, availability rules and audit. DATABASE_URL=postgres://localhost:5432/booking # Required. The zone your working hours are written in. Changing it changes every future slot. OWNER_ZONE=Europe/London # Required · secret. Signs management links. Rotating it invalidates outstanding links, so plan for it. LINK_SIGNING_SECRET=hex-64-chars-redacted # Required · secret. Calendar provider credentials. Least scopes that still return busy time. CALENDAR_CLIENT_SECRET=redacted # Required. Beyond this the page narrows availability and says so. BUSY_MAX_STALE_MINUTES=15 # Required. How long a slot is held while a form is open. HOLD_EXPIRY_SECONDS=600 # Required. How far ahead reminders are scheduled. REMINDER_LEAD_MINUTES=1440
# OnceHub · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For OnceHub, publish booking pages with routing rules, forms, and reminders. The hard boundary is routing depth, calendar integrations, crm workflows, and dependable scheduling, plus calendar integrations and reliability. ## Product outcome A booking page that cannot double-book because a database constraint says so, that still holds the right time after a political time zone change, and that fails closed when it cannot see your calendar. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - PostgreSQL - public deploy target - Resend API key - domain with HTTPS ## Explicit non-goals for v1 - 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 ## Success criteria - Concurrent submissions are confirmed to produce one booking - Working hours are confirmed stable across a daylight saving transition - A dead calendar endpoint is confirmed to narrow availability - A forged management token is confirmed refused - Re-resolution is confirmed to report without sending anything
# Build brief · OnceHub The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a single-person booking page that stores meetings as wall time in a named zone and can prove it never double-booked to replace OnceHub. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and the first one is the reason this build takes longer than it looks. Never store a future booking only as an instant. Time zone rules are political and they change after you have taken the booking. The current example is concrete: tz database release 2026d, announced in September 2026, records that Canada's Northwest Territories will not fall back on 2026-11-01 and will stay on minus six year round, which moves America/Inuvik for every date after that. A meeting booked in July for a December afternoon in Inuvik, converted to a UTC instant under the old rules and stored as that instant, is now an hour wrong, and no column in your database will say so. Store the local wall time and the IANA zone identifier the person chose, record the tz database version the row was created under, and compute the instant when you render or send. After a tz database upgrade, re-resolve every future booking and report the ones that moved rather than silently emailing a new time. Second: a double booking is the failure that ends the tool, because the cost lands on someone else's morning. Availability is not a read followed by a write. Reserve inside one transaction with a constraint the database enforces, so two simultaneous bookers cannot both win, and test it with two concurrent requests rather than reasoning about it. ### Stack (fixed, do not substitute) - Runtime: Next.js with TypeScript, one public booking page and one private admin · the public page is the product and it has to be fast and boring - Storage: PostgreSQL with an exclusion constraint on the booking range · the database is the only thing that can settle a race between two bookers - Time: Wall time plus IANA zone on every future row, resolved at render · offsets change after the booking is taken and a stored instant cannot know - Calendar: One provider, read for busy time, write the event, reconcile on a schedule · a calendar is an input, and treating it as the source of truth means trusting a network call - Mail: A transactional provider for confirmations and reminders, with signed management links · a booking nobody was told about is not a booking ### Data model (create this before Phase 1) - 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. ### Phase 1 · Availability from rules, in a named zone Build: Event types with duration, buffers, minimum notice and a booking horizon. Working hours stored as local times in the owner's zone. Generate candidate slots for the horizon, subtract buffers and minimum notice, and render them in the visitor's detected zone with the zone name shown and changeable. Store the tz database version the server is running. Trap: 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. Done when: Slot generation across a daylight saving transition produces the same local working hours on both sides, tested with a fixture date either side of a real transition. The visitor's zone is displayed by name and can be changed, and changing it re-renders the same slots at different local times. Minimum notice and horizon are enforced server side, tested by requesting a slot outside both. The running tz database version is recorded and shown on an admin page. Do not build yet: No bookings, no holds, no calendar, no email. ### Phase 2 · Reserving a slot without a race Build: A hold created when the booking form opens, with a short expiry. On submit, insert the booking inside a transaction protected by a range exclusion constraint covering the slot plus its buffers. Let the database refuse the second writer and turn that refusal into a readable message with the next available slots offered. Trap: 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. Done when: Two concurrent submissions for the same slot produce exactly one booking and one refusal, tested with parallel requests against a real database. The refusal offers alternative slots rather than a stack trace. An expired hold frees its slot automatically and the expiry is tested at the boundary. Buffers are included in the constraint, so a booking cannot start inside the previous meeting's buffer, tested. Do not build yet: No calendar writing, no email, no cancellation. ### Phase 3 · The calendar is an input you reconcile Build: Connect one calendar provider. Read busy intervals for the horizon and subtract them from generated slots, with a cache and a stated maximum staleness. Write an event when a booking is confirmed and store its external id. Run a reconciliation pass that compares your bookings against the calendar and reports differences instead of correcting them silently. Trap: 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. Done when: A failed or stale busy read narrows availability and shows a notice, tested by pointing the client at a dead endpoint. A confirmed booking creates an event and stores its external id, and deleting that event externally is reported by reconciliation rather than silently recreated. The reconciliation pass records its own last run time and an admin page shows the age. Token expiry produces one clear reconnect prompt, not a failure on every page load. Do not build yet: No email, no cancellation links, no tz upgrade handling. ### Phase 4 · Telling people, and letting them change their mind Build: Send a confirmation on booking, a reminder at a configured lead time, and a cancellation notice. Management links carry a signed token scoped to one booking with an expiry. Reminders are scheduled rows, claimed by a worker with a conditional update so a restart cannot send twice. Trap: 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. Done when: A management link for one booking cannot be edited into a valid link for another, tested by altering the token. Reminder sends are idempotent across a worker restart mid-send, tested by killing the worker between claim and send. A cancellation updates the booking, removes the calendar event and sends one notice, with an audit row for each step. Mail failures are retried with backoff and surfaced on an admin page rather than lost. Do not build yet: No tz upgrade handling, no export, no operational documentation. ### Phase 5 · Surviving a time zone upgrade, and getting out Build: A command that re-resolves every future booking under the current tz database, compares against the stored cached instant, and prints the bookings whose instant moved along with who is affected. Nothing is emailed automatically. Export bookings, event types and audit rows as CSV, and delete a booker's details on request while keeping the slot and the audit trail. Trap: 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. Done when: Running the re-resolution command against a fixture with a changed zone rule lists exactly the affected bookings and changes nothing else, tested. No code path emails a corrected time without an explicit confirmation, verified by review and asserted in the suite. Export produces bookings, event types and audit rows as CSV and reimports into an empty database with the same counts. Deleting a booker's personal details keeps the slot, the audit trail and the totals intact, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Round-robin and team routing, which needs several calendars, a fairness rule and a shared availability model, and is a different product from one person's page. - Payments at booking time, since taking money adds refunds, disputes and a processor's rules to a tool whose job is to not double-book. - More than one calendar provider, because each one has its own permissions, quirks and reconnect behaviour and the second one doubles the maintenance. - Routing forms that branch to different event types, which is worth building only once the single path is provably conflict free. ### README must contain - That future bookings store a wall time and an IANA zone, not an instant, and exactly why - The tz database version the service is running and how to run the re-resolution command after an upgrade - That conflict prevention lives in a database constraint, with the migration that creates it named - That availability fails closed when calendar data is stale, and where the staleness threshold is set - How management links are signed and scoped, and what to do if the signing secret is rotated
# Architecture · OnceHub ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with TypeScript, one public booking page and one private admin | the public page is the product and it has to be fast and boring | | Storage | PostgreSQL with an exclusion constraint on the booking range | the database is the only thing that can settle a race between two bookers | | Time | Wall time plus IANA zone on every future row, resolved at render | offsets change after the booking is taken and a stored instant cannot know | | Calendar | One provider, read for busy time, write the event, reconcile on a schedule | a calendar is an input, and treating it as the source of truth means trusting a network call | | Mail | A transactional provider for confirmations and reminders, with signed management links | a booking nobody was told about is not a booking | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Rules, generation in wall time and zone rendering. | Any generator, provided it works in local time and resolves afterwards. | | Reservation | Holds, the exclusion constraint and refusal handling. | Not swappable for a read-then-write check, whatever the framework suggests. | | Calendar | Busy reads, event writes and reconciliation. | Any provider, provided a failed read narrows rather than widens availability. | | Notify | Signed links, confirmations and idempotent reminders. | Any mail provider, provided sends are claimed before they are attempted. | | Timekeeping | tz database version, re-resolution and the change report. | Any implementation, provided nothing emails a corrected time on its own. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required · Bookings, holds, availability rules and audit. - `OWNER_ZONE` · required · The zone your working hours are written in. Changing it changes every future slot. - `LINK_SIGNING_SECRET` · required, secret · Signs management links. Rotating it invalidates outstanding links, so plan for it. - `CALENDAR_CLIENT_SECRET` · required, secret · Calendar provider credentials. Least scopes that still return busy time. - `BUSY_MAX_STALE_MINUTES` · required · Beyond this the page narrows availability and says so. - `HOLD_EXPIRY_SECONDS` · required · How long a slot is held while a form is open. - `REMINDER_LEAD_MINUTES` · required · How far ahead reminders are scheduled. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · OnceHub product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with TypeScript, one public booking page and one private admin, PostgreSQL with an exclusion constraint on the booking range, Wall time plus IANA zone on every future row, resolved at render, One provider, read for busy time, write the event, reconcile on a schedule, A transactional provider for confirmations and reminders, with signed management links. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - 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. - 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. - 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. - 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. - 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.
# Delivery milestones · OnceHub Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · 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. ### Steps 1. Generate candidates in the owner's wall time Adding hours to a UTC instant is what breaks in October. Files: `src/lib/availability.ts`, `tests/availability.dst.test.ts` 2. Show the visitor's zone by name and let them change it Detection is a guess and the guess is wrong for travellers. Files: `src/app/[slug]/page.tsx` 3. Record the tz database version the server runs You cannot re-resolve safely without knowing what you resolved under. Files: `src/lib/tzdb.ts` ### Done when - [ ] Working hours are stable across a transition - [ ] Visitor zone is named and changeable - [ ] Notice and horizon are enforced server side - [ ] The tz database version is recorded ### 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. ## M2 · Reserving a slot without a race Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the conflict rule in a database constraint It is the only referee both requests can hear. Files: `src/db/migrations/002_exclusion.sql`, `tests/concurrent-booking.test.ts` 2. Hold the slot while the form is open, briefly Long holds are a denial of service on your own calendar. Files: `src/lib/holds.ts` 3. Turn a constraint violation into an offer Somebody lost the race and still wants a meeting. Files: `src/app/api/book/route.ts` ### Done when - [ ] Concurrent submissions yield one booking - [ ] A refusal offers alternatives - [ ] Holds expire and free the slot - [ ] Buffers participate in the constraint ### 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. ## M3 · 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. ### Steps 1. Fail closed when busy data is stale or missing An empty busy list and a broken call look identical. Files: `src/lib/busy.ts`, `tests/busy-failclosed.test.ts` 2. Store the external event id with the booking Without it, reconciliation is guesswork over titles. Files: `src/lib/calendar.ts` 3. Report drift, do not silently repair it Someone deleted that event on purpose, possibly. Files: `src/jobs/reconcile.ts` ### Done when - [ ] Stale busy data narrows availability - [ ] Bookings store their external event id - [ ] Reconciliation reports rather than repairs - [ ] Reconnect is prompted once, not per page ### 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. ## M4 · Telling people, and letting them change their mind Confirmations, reminders and cancellations work through links that cannot be guessed or replayed. ### Steps 1. Sign management tokens, scope them, expire them A sequential id in a URL is an invitation. Files: `src/lib/tokens.ts`, `tests/token-forgery.test.ts` 2. Claim reminders before sending them A restart between claim and send should cost nothing. Files: `src/jobs/reminders.ts` 3. Audit every state change, including automatic ones The disputed booking is always one you did not touch. Files: `src/lib/audit.ts` ### Done when - [ ] A forged management token is refused - [ ] Reminders survive a restart without duplicates - [ ] Cancellation updates booking, calendar and mail - [ ] Mail failures are visible and retried ### 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. ## M5 · Surviving a time zone upgrade, and getting out (production only) A tz database update is a routine operation with a report, and everything exports. ### Steps 1. Make re-resolution a command with a report A time that moved is news, not a migration side effect. Files: `src/cli/reresolve.ts`, `tests/reresolve.test.ts` 2. Require confirmation before any correction email The invitation in their calendar is the one they trust. Files: `src/jobs/corrections.ts` 3. Delete booker details without deleting the record The slot happened; the person's address need not persist. Files: `src/lib/erasure.ts` ### Done when - [ ] Re-resolution lists affected bookings only - [ ] No automatic correction email exists - [ ] Export and import round-trip every table - [ ] Erasure keeps the slot and the audit trail ### Watch 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.
# Operations · OnceHub ## Backup The database, nightly, and confirm the exclusion constraint exists in the restored schema. A restore that drops the constraint restores a service that will double-book on the first busy morning. ## Restore Restore, run the re-resolution command, and compare future booking counts and instants against the export. Then reconnect the calendar, because tokens do not survive an environment change. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Age of the last reconciliation, age of the busy cache, reminder failures, and the count of bookings whose re-resolved instant differs from the cache. The last number should be zero except in the days after a tz upgrade. ## Incident checklist A double booking and a wrong time are the two incidents, and both are quiet. The audit table plus the stored tz database version are what let you reconstruct what the visitor was shown and what was written, rather than arguing from memory. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Concurrent submissions are confirmed to produce one booking - [ ] Working hours are confirmed stable across a daylight saving transition - [ ] A dead calendar endpoint is confirmed to narrow availability - [ ] A forged management token is confirmed refused - [ ] Re-resolution is confirmed to report without sending anything ## Launch constraint Do not market omitted OnceHub capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds, availability rules and audit. DATABASE_URL=postgres://localhost:5432/booking # Required. The zone your working hours are written in. Changing it changes every future slot. OWNER_ZONE=Europe/London # Required · secret. Signs management links. Rotating it invalidates outstanding links, so plan for it. LINK_SIGNING_SECRET=hex-64-chars-redacted # Required · secret. Calendar provider credentials. Least scopes that still return busy time. CALENDAR_CLIENT_SECRET=redacted # Required. Beyond this the page narrows availability and says so. BUSY_MAX_STALE_MINUTES=15 # Required. How long a slot is held while a form is open. HOLD_EXPIRY_SECONDS=600 # Required. How far ahead reminders are scheduled. REMINDER_LEAD_MINUTES=1440
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for OnceHub because people pay because one double booking or missed invite can cost more than years of subscription fees. The recurring cost buys OAuth refresh, webhooks, timezone databases, race conditions, reminders, deliverability, privacy, and uptime, not just the visible interface.
xrouting depth, calendar integrations, CRM workflows, and dependable scheduling
xmultiple calendar providers
xteam routing and round-robin
xpayments
xtimezone edge-case maturity
OnceHub pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0/user | $0/user | 1 user; 1 calendar; 1 booking link; 6 weeks of analytics/activity history. |
| schedule | $12/user | $10/user | Unlimited booking links, calendars, and meetings; 1 phone number. |
| route | $23/user | $19/user | Unlimited routing forms and responses; 2 phone numbers. |
| engage | $47/user | $39/user | Unlimited chatbots and conversations; 3 phone numbers. |
| enterprise | custom | — | Designed for 30+ users; Security & Compliance add-on included. |
free tier1 user; 1 calendar; 1 booking link; 6 weeks of analytics/activity history.
billingmonthly + annual (about -17%); Enterprise custom
hidden costsSecurity & Compliance costs $6/user/month monthly or $5/user/month annually outside Enterprise; extra phone numbers cost $4/month; SMS uses separately purchased credits.
verified 2026-08-14 · source ↗
Vibecode OnceHub
Kinda. The core of OnceHub is buildable in a weekend with the prompt on this page, but there are real gaps: routing depth, calendar integrations, CRM workflows, and dependable scheduling, multiple calendar providers. Read the honest list above before committing.
How much does OnceHub cost?
OnceHub costs about $12/month (Schedule, checked 2026-07-31), which is $144 per year.
What do I lose by replacing OnceHub?
Honestly: routing depth, calendar integrations, CRM workflows, and dependable scheduling; multiple calendar providers; team routing and round-robin; payments; timezone edge-case maturity. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to OnceHub?
Yes: Cal.com (Mature open-source scheduling platform with calendar integrations and booking workflows.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.