Vibecode Book Like A Boss
track this build4 phases, 12 steps, beginner friendly0%The visible booking storefront loop is buildable, but a credible replacement needs more than the first screen. Book Like A Boss earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Book Like A Boss. 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 ===== # Book Like A Boss · indie build A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. Estimated effort: **weekend**. 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 | Node with TypeScript and server-rendered pages, progressive enhancement only | a booking page is one form and it has to work on a bad phone connection | | Database | PostgreSQL 16 or newer with btree_gist installed | it is the only component here that can make an overlapping booking impossible | | Times | Instants as timestamptz, rules as local time plus an IANA zone identifier | an offset is a fact about one date, not a rule you can store | | Calendar | Free and busy intervals in, events out with an explicit zone identifier | titles and attendees from your calendar are not yours to publish | | Mail | One provider, with every message written to an outbox table before the response returns | a confirmation that silently failed is worse than a booking that visibly failed | ## 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 application and the outbox worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with btree_gist available** · free locally - Why: The exclusion constraint is the whole double-booking defence and no other database in reach offers it. - Get it: Install locally or use a managed instance, then run CREATE EXTENSION btree_gist. - Verify: The extension creates and a deliberately overlapping insert is rejected - [ ] **A calendar account with free and busy access** · free - Why: Availability that ignores your real calendar is the failure this build exists to prevent. - Get it: Create OAuth credentials for your calendar provider with the narrowest scope that returns busy intervals. - Verify: A busy interval you created by hand appears in the cache with no title - [ ] **A transactional mail provider key** · free tier is usually enough for a personal page - Why: Confirmations are the part clients notice, and a provider that silently drops them is indistinguishable from a broken booking. - Get it: Any provider with an API key and a verified sender address. - Verify: A queued outbox row is sent and marked, and a failing key leaves it visibly stuck - [ ] **Your policy for non-existent and ambiguous local times** · free - Why: Two days a year a rule's local start either does not happen or happens twice, and deciding this at the moment it breaks means deciding it badly. - Get it: Write down what the app does on each, and turn both into tests. - Verify: Both tests exist and the README states the policy ## 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: - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. - reliable reminders and timezone edge cases - multi-calendar conflict handling - race-free booking under load - team routing and round robin If one of those is essential to you, that is the reason to keep paying for Book Like A Boss, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Book Like A Boss 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 personal booking page that publishes real availability and cannot double-book you to replace Book Like A Boss. 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. Both are about trust, and a booking page that loses either is not worth running. First: the database prevents the double booking, not the interface. Availability rendered in a browser is a photograph of the past, and the gap between render and submit is long enough for somebody else to take the slot. PostgreSQL can make an overlap impossible, and its own documentation gives the shape: after 'CREATE EXTENSION btree_gist', a table declared with 'EXCLUDE USING GIST (room WITH =, during WITH &&)' rejects overlapping ranges only when the room matches. Checked against the PostgreSQL range types documentation on 15 September 2026. SQLite has nothing equivalent, which is why the database is fixed here and is not a preference. A select-then-insert check is not a substitute. Under the default isolation level two sessions both read an empty result and both insert. Second: only busy intervals leave the connected calendar. Not titles, not attendees, not locations. A stranger looking for a half hour on Thursday has no business learning that you have a hospital appointment on Wednesday, and the free/busy shape of the query is what keeps that accidental leak impossible rather than merely unlikely. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript and server-rendered pages, progressive enhancement only · a booking page is one form and it has to work on a bad phone connection - Database: PostgreSQL 16 or newer with btree_gist installed · it is the only component here that can make an overlapping booking impossible - Times: Instants as timestamptz, rules as local time plus an IANA zone identifier · an offset is a fact about one date, not a rule you can store - Calendar: Free and busy intervals in, events out with an explicit zone identifier · titles and attendees from your calendar are not yours to publish - Mail: One provider, with every message written to an outbox table before the response returns · a confirmation that silently failed is worse than a booking that visibly failed ### Data model (create this before Phase 1) - resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books. ### Phase 1 · Availability computed from rules, not stored as slots Build: Store availability as weekday, local start, local end and an IANA zone. Expand to instants for a requested window using a real time zone library. Subtract exceptions, apply minimum notice and maximum horizon, and render in the booker's zone with the zone named on the page. Trap: Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. Done when: Slots for a week containing a spring transition and a week containing an autumn transition both start at the configured local time, tested against a fixed clock. A rule whose local start does not exist on the spring day is handled by a stated policy rather than throwing, tested. The booker's zone is shown beside every time on the page. No slot appears inside the minimum notice period or beyond the maximum horizon, tested at both boundaries. Do not build yet: No booking, calendar connection, email or cancellation yet. ### Phase 2 · The database refuses the overlap Build: Install btree_gist. Declare the bookings range column with an exclusion constraint on resource equality and range overlap, restricted to confirmed rows so cancellations free the slot. Include the buffers in the stored range. Catch the constraint violation and return a refusal that re-renders current availability rather than a generic error page. Trap: Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. Done when: Two concurrent inserts for the same slot on separate connections yield one confirmed booking and one constraint violation, tested. Cancelling a booking frees the slot immediately for a new booking, tested. Buffers before and after are inside the stored range, so a booking cannot start in another's buffer, tested. The refusal path returns fresh availability and a message naming the slot that went, not a stack trace. Do not build yet: No calendar reading, write-back, email or reschedule yet. ### Phase 3 · Busy in, events out, and nothing else Build: Read free and busy intervals for the resource's calendars and cache them with a fetch time. Never request or store titles, attendees or locations. Write the confirmed booking back as an event with an explicit zone identifier, a stable identifier of your own, and a sequence number you increment on every later change. Store the remote identifier against the booking. Trap: Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. Done when: The busy cache table has no column capable of holding a title, attendee or location, verified by schema review and a test that inspects the columns. A confirmed booking appears in the connected calendar at the right wall clock time in the resource's zone, checked manually once and asserted against the generated payload. Changing a booking increments the sequence and updates the same event rather than creating a second one, tested. Every busy row records when it was fetched. Do not build yet: No email, cancellation links or stale-data policy yet. ### Phase 4 · Confirmations that actually leave, and a way to cancel Build: Write the confirmation into an outbox row inside the same transaction as the booking. A worker sends and marks, retrying with backoff and stopping at a dead state that is visible. Include both the booker's local time and the resource's. Issue a signed cancellation and reschedule link that does not require an account and expires with the booking. Trap: Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. Done when: A booking made while the mail provider is refusing connections still confirms and leaves an outbox row that sends when the provider returns, tested with a failing stub. The confirmation states the time in both zones with the zone names. A cancellation link cancels without an account, frees the slot and updates the calendar event, tested end to end. A link for a booking that already ended is refused with an explanation. Do not build yet: No stale-data policy, export or operational dashboard yet. ### Phase 5 · What the page does when the calendar is down Build: Give the busy cache a maximum age. Past it, stop offering slots and say the calendar is unreachable rather than falling back to rules alone. Show a dashboard of stuck outbox rows, failed calendar writes and cache age. Export bookings and the outbox as CSV. Keep a booking's history so a cancelled slot can still be explained months later. Trap: Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. Done when: With the calendar unreachable and the cache past its maximum age, the page offers no slots and explains why, tested with a stubbed failure and a clock. The dashboard shows cache age, stuck outbox rows and failed calendar writes, each with the time it started failing. Export produces every booking, its state history and its outbox rows, and reimports into an empty database. Deleting a booking is impossible; cancellation keeps the row and its history. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a different availability model and a fairness rule you would have to argue about. - Taking payment at booking time, which brings refunds, disputes and receipts, all of which are bigger than the booking page. - Text message reminders, which cost money per message and need delivery handling of their own before they are worth trusting. - A hosted service for other people's calendars, which makes you responsible for other people's private event data and changes the risk entirely. ### README must contain - That PostgreSQL is fixed because the exclusion constraint is what makes an overlapping booking impossible, with the documentation quoted and the date checked - That a select-then-insert conflict check is not a substitute, and why the concurrency test exists - That availability rules are stored as local time and a zone, with the two daylight saving tests named - That only busy intervals are read from the connected calendar, and that the cache table has no column for a title - That stale busy data closes the booking page instead of falling back to the rules ===== AGENTS.md ===== # Agent instructions · Book Like A Boss indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript and server-rendered pages, progressive enhancement only, PostgreSQL 16 or newer with btree_gist installed, Instants as timestamptz, rules as local time plus an IANA zone identifier, Free and busy intervals in, events out with an explicit zone identifier, One provider, with every message written to an outbox table before the response returns. 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 - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ===== BUILD_PLAN.md ===== # Build plan · Book Like A Boss A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. 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 computed from rules, not stored as slots A week of slots is derived from local-time rules and survives a daylight saving change. ### Steps 1. Store rules in local time with a zone identifier An offset is only true until the next transition. Files: `src/db/schema.sql`, `src/availability/rules.ts` 2. Expand per date with a time zone library, never once into UTC The expansion is the part that has to be aware of the transition. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 3. Name the zone beside every displayed time Both sides of a booking should be able to see which clock it means. Files: `src/views/book.ejs` ### Done when - [ ] Slots hold their local start across both transitions - [ ] A non-existent local time has a stated policy and a test - [ ] The booker's zone is displayed with every time - [ ] Notice and horizon bounds are enforced at the edges ### Watch out - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. ## Phase 2 · The database refuses the overlap Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Declare the exclusion constraint and prove it fires This is the single most important line in the schema. Files: `src/db/schema.sql`, `tests/concurrency.test.ts` 2. Put the buffers inside the stored range Otherwise back-to-back bookings leave you no walk between them. Files: `src/booking/create.ts` 3. Turn the violation into a helpful refusal The person is still there and still wants a time. Files: `src/booking/create.ts`, `src/views/taken.ejs` ### Done when - [ ] Concurrent inserts leave one booking and one refusal - [ ] Cancellation frees the slot at once - [ ] Buffers are inside the stored range - [ ] A refusal re-renders availability, not an error ### Watch out - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. ## Phase 3 · Busy in, events out, and nothing else The connected calendar contributes intervals only, and the booking appears in it correctly. ### Steps 1. Request busy intervals, and give the cache nowhere to put a title The schema is the enforcement; the query is only the intent. Files: `src/calendar/freebusy.ts`, `src/db/schema.sql` 2. Write back with an explicit zone identifier and your own stable identifier A floating time in someone else's calendar means a different hour. Files: `src/calendar/write.ts` 3. Increment a sequence on every change It is what makes the second write an update instead of a duplicate. Files: `src/calendar/write.ts`, `tests/sequence.test.ts` ### Done when - [ ] The busy cache cannot hold a title or attendee - [ ] The written event lands at the right local time - [ ] A change updates the event rather than duplicating it - [ ] Every busy row carries a fetch time ### Watch out - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. ## Phase 4 · Confirmations that actually leave, and a way to cancel Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved. ### Steps 1. Queue the message inside the booking transaction Either both happened or neither did. Files: `src/outbox/enqueue.ts`, `src/booking/create.ts` 2. Send from a worker with backoff and a visible dead state A silent retry loop is how a week of confirmations goes missing. Files: `src/outbox/worker.ts`, `tests/outbox.test.ts` 3. Sign the cancellation link and expire it with the booking No account, no guessable identifiers. Files: `src/links.ts`, `tests/links.test.ts` ### Done when - [ ] A booking survives a failing mail provider - [ ] The confirmation names both zones - [ ] Cancelling frees the slot and updates the event - [ ] An expired link is refused with a reason ### Watch out - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ## Not in this build - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. ## After v1, if you want it - Intake questions attached to a booking type, stored as answers rather than as a blob of form fields - A second calendar as a read-only busy source, which is mostly a loop over the same fetch - An availability preview that shows the next two weeks exactly as a booker would see them ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. PostgreSQL with btree_gist installed. The constraint lives here. DATABASE_URL=postgres://localhost:5432/booking # Required. The IANA zone your availability rules are written in. Not an offset. RESOURCE_ZONE=Europe/London # Required. OAuth client for free and busy access. CALENDAR_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=GOCSPX-...redacted # Required. Past this the busy cache counts as no data and the page stops offering slots. BUSY_MAX_AGE_MINUTES=15 # Required · secret. Transactional mail. The outbox worker is the only caller. MAIL_API_KEY=re_...redacted # Required · secret. Signs cancellation and reschedule links. Rotating it invalidates outstanding links. LINK_SIGNING_KEY=a-long-random-string-redacted # Required. How far ahead the page will offer anything. BOOKING_HORIZON_DAYS=60
You are building a lean indie version of Book Like A Boss. 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 ===== # Book Like A Boss · indie build A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. Estimated effort: **weekend**. 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 | Node with TypeScript and server-rendered pages, progressive enhancement only | a booking page is one form and it has to work on a bad phone connection | | Database | PostgreSQL 16 or newer with btree_gist installed | it is the only component here that can make an overlapping booking impossible | | Times | Instants as timestamptz, rules as local time plus an IANA zone identifier | an offset is a fact about one date, not a rule you can store | | Calendar | Free and busy intervals in, events out with an explicit zone identifier | titles and attendees from your calendar are not yours to publish | | Mail | One provider, with every message written to an outbox table before the response returns | a confirmation that silently failed is worse than a booking that visibly failed | ## 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 application and the outbox worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with btree_gist available** · free locally - Why: The exclusion constraint is the whole double-booking defence and no other database in reach offers it. - Get it: Install locally or use a managed instance, then run CREATE EXTENSION btree_gist. - Verify: The extension creates and a deliberately overlapping insert is rejected - [ ] **A calendar account with free and busy access** · free - Why: Availability that ignores your real calendar is the failure this build exists to prevent. - Get it: Create OAuth credentials for your calendar provider with the narrowest scope that returns busy intervals. - Verify: A busy interval you created by hand appears in the cache with no title - [ ] **A transactional mail provider key** · free tier is usually enough for a personal page - Why: Confirmations are the part clients notice, and a provider that silently drops them is indistinguishable from a broken booking. - Get it: Any provider with an API key and a verified sender address. - Verify: A queued outbox row is sent and marked, and a failing key leaves it visibly stuck - [ ] **Your policy for non-existent and ambiguous local times** · free - Why: Two days a year a rule's local start either does not happen or happens twice, and deciding this at the moment it breaks means deciding it badly. - Get it: Write down what the app does on each, and turn both into tests. - Verify: Both tests exist and the README states the policy ## 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: - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. - reliable reminders and timezone edge cases - multi-calendar conflict handling - race-free booking under load - team routing and round robin If one of those is essential to you, that is the reason to keep paying for Book Like A Boss, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Book Like A Boss 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 personal booking page that publishes real availability and cannot double-book you to replace Book Like A Boss. 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. Both are about trust, and a booking page that loses either is not worth running. First: the database prevents the double booking, not the interface. Availability rendered in a browser is a photograph of the past, and the gap between render and submit is long enough for somebody else to take the slot. PostgreSQL can make an overlap impossible, and its own documentation gives the shape: after 'CREATE EXTENSION btree_gist', a table declared with 'EXCLUDE USING GIST (room WITH =, during WITH &&)' rejects overlapping ranges only when the room matches. Checked against the PostgreSQL range types documentation on 15 September 2026. SQLite has nothing equivalent, which is why the database is fixed here and is not a preference. A select-then-insert check is not a substitute. Under the default isolation level two sessions both read an empty result and both insert. Second: only busy intervals leave the connected calendar. Not titles, not attendees, not locations. A stranger looking for a half hour on Thursday has no business learning that you have a hospital appointment on Wednesday, and the free/busy shape of the query is what keeps that accidental leak impossible rather than merely unlikely. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript and server-rendered pages, progressive enhancement only · a booking page is one form and it has to work on a bad phone connection - Database: PostgreSQL 16 or newer with btree_gist installed · it is the only component here that can make an overlapping booking impossible - Times: Instants as timestamptz, rules as local time plus an IANA zone identifier · an offset is a fact about one date, not a rule you can store - Calendar: Free and busy intervals in, events out with an explicit zone identifier · titles and attendees from your calendar are not yours to publish - Mail: One provider, with every message written to an outbox table before the response returns · a confirmation that silently failed is worse than a booking that visibly failed ### Data model (create this before Phase 1) - resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books. ### Phase 1 · Availability computed from rules, not stored as slots Build: Store availability as weekday, local start, local end and an IANA zone. Expand to instants for a requested window using a real time zone library. Subtract exceptions, apply minimum notice and maximum horizon, and render in the booker's zone with the zone named on the page. Trap: Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. Done when: Slots for a week containing a spring transition and a week containing an autumn transition both start at the configured local time, tested against a fixed clock. A rule whose local start does not exist on the spring day is handled by a stated policy rather than throwing, tested. The booker's zone is shown beside every time on the page. No slot appears inside the minimum notice period or beyond the maximum horizon, tested at both boundaries. Do not build yet: No booking, calendar connection, email or cancellation yet. ### Phase 2 · The database refuses the overlap Build: Install btree_gist. Declare the bookings range column with an exclusion constraint on resource equality and range overlap, restricted to confirmed rows so cancellations free the slot. Include the buffers in the stored range. Catch the constraint violation and return a refusal that re-renders current availability rather than a generic error page. Trap: Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. Done when: Two concurrent inserts for the same slot on separate connections yield one confirmed booking and one constraint violation, tested. Cancelling a booking frees the slot immediately for a new booking, tested. Buffers before and after are inside the stored range, so a booking cannot start in another's buffer, tested. The refusal path returns fresh availability and a message naming the slot that went, not a stack trace. Do not build yet: No calendar reading, write-back, email or reschedule yet. ### Phase 3 · Busy in, events out, and nothing else Build: Read free and busy intervals for the resource's calendars and cache them with a fetch time. Never request or store titles, attendees or locations. Write the confirmed booking back as an event with an explicit zone identifier, a stable identifier of your own, and a sequence number you increment on every later change. Store the remote identifier against the booking. Trap: Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. Done when: The busy cache table has no column capable of holding a title, attendee or location, verified by schema review and a test that inspects the columns. A confirmed booking appears in the connected calendar at the right wall clock time in the resource's zone, checked manually once and asserted against the generated payload. Changing a booking increments the sequence and updates the same event rather than creating a second one, tested. Every busy row records when it was fetched. Do not build yet: No email, cancellation links or stale-data policy yet. ### Phase 4 · Confirmations that actually leave, and a way to cancel Build: Write the confirmation into an outbox row inside the same transaction as the booking. A worker sends and marks, retrying with backoff and stopping at a dead state that is visible. Include both the booker's local time and the resource's. Issue a signed cancellation and reschedule link that does not require an account and expires with the booking. Trap: Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. Done when: A booking made while the mail provider is refusing connections still confirms and leaves an outbox row that sends when the provider returns, tested with a failing stub. The confirmation states the time in both zones with the zone names. A cancellation link cancels without an account, frees the slot and updates the calendar event, tested end to end. A link for a booking that already ended is refused with an explanation. Do not build yet: No stale-data policy, export or operational dashboard yet. ### Phase 5 · What the page does when the calendar is down Build: Give the busy cache a maximum age. Past it, stop offering slots and say the calendar is unreachable rather than falling back to rules alone. Show a dashboard of stuck outbox rows, failed calendar writes and cache age. Export bookings and the outbox as CSV. Keep a booking's history so a cancelled slot can still be explained months later. Trap: Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. Done when: With the calendar unreachable and the cache past its maximum age, the page offers no slots and explains why, tested with a stubbed failure and a clock. The dashboard shows cache age, stuck outbox rows and failed calendar writes, each with the time it started failing. Export produces every booking, its state history and its outbox rows, and reimports into an empty database. Deleting a booking is impossible; cancellation keeps the row and its history. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a different availability model and a fairness rule you would have to argue about. - Taking payment at booking time, which brings refunds, disputes and receipts, all of which are bigger than the booking page. - Text message reminders, which cost money per message and need delivery handling of their own before they are worth trusting. - A hosted service for other people's calendars, which makes you responsible for other people's private event data and changes the risk entirely. ### README must contain - That PostgreSQL is fixed because the exclusion constraint is what makes an overlapping booking impossible, with the documentation quoted and the date checked - That a select-then-insert conflict check is not a substitute, and why the concurrency test exists - That availability rules are stored as local time and a zone, with the two daylight saving tests named - That only busy intervals are read from the connected calendar, and that the cache table has no column for a title - That stale busy data closes the booking page instead of falling back to the rules ===== AGENTS.md ===== # Agent instructions · Book Like A Boss indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript and server-rendered pages, progressive enhancement only, PostgreSQL 16 or newer with btree_gist installed, Instants as timestamptz, rules as local time plus an IANA zone identifier, Free and busy intervals in, events out with an explicit zone identifier, One provider, with every message written to an outbox table before the response returns. 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 - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ===== BUILD_PLAN.md ===== # Build plan · Book Like A Boss A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. 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 computed from rules, not stored as slots A week of slots is derived from local-time rules and survives a daylight saving change. ### Steps 1. Store rules in local time with a zone identifier An offset is only true until the next transition. Files: `src/db/schema.sql`, `src/availability/rules.ts` 2. Expand per date with a time zone library, never once into UTC The expansion is the part that has to be aware of the transition. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 3. Name the zone beside every displayed time Both sides of a booking should be able to see which clock it means. Files: `src/views/book.ejs` ### Done when - [ ] Slots hold their local start across both transitions - [ ] A non-existent local time has a stated policy and a test - [ ] The booker's zone is displayed with every time - [ ] Notice and horizon bounds are enforced at the edges ### Watch out - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. ## Phase 2 · The database refuses the overlap Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Declare the exclusion constraint and prove it fires This is the single most important line in the schema. Files: `src/db/schema.sql`, `tests/concurrency.test.ts` 2. Put the buffers inside the stored range Otherwise back-to-back bookings leave you no walk between them. Files: `src/booking/create.ts` 3. Turn the violation into a helpful refusal The person is still there and still wants a time. Files: `src/booking/create.ts`, `src/views/taken.ejs` ### Done when - [ ] Concurrent inserts leave one booking and one refusal - [ ] Cancellation frees the slot at once - [ ] Buffers are inside the stored range - [ ] A refusal re-renders availability, not an error ### Watch out - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. ## Phase 3 · Busy in, events out, and nothing else The connected calendar contributes intervals only, and the booking appears in it correctly. ### Steps 1. Request busy intervals, and give the cache nowhere to put a title The schema is the enforcement; the query is only the intent. Files: `src/calendar/freebusy.ts`, `src/db/schema.sql` 2. Write back with an explicit zone identifier and your own stable identifier A floating time in someone else's calendar means a different hour. Files: `src/calendar/write.ts` 3. Increment a sequence on every change It is what makes the second write an update instead of a duplicate. Files: `src/calendar/write.ts`, `tests/sequence.test.ts` ### Done when - [ ] The busy cache cannot hold a title or attendee - [ ] The written event lands at the right local time - [ ] A change updates the event rather than duplicating it - [ ] Every busy row carries a fetch time ### Watch out - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. ## Phase 4 · Confirmations that actually leave, and a way to cancel Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved. ### Steps 1. Queue the message inside the booking transaction Either both happened or neither did. Files: `src/outbox/enqueue.ts`, `src/booking/create.ts` 2. Send from a worker with backoff and a visible dead state A silent retry loop is how a week of confirmations goes missing. Files: `src/outbox/worker.ts`, `tests/outbox.test.ts` 3. Sign the cancellation link and expire it with the booking No account, no guessable identifiers. Files: `src/links.ts`, `tests/links.test.ts` ### Done when - [ ] A booking survives a failing mail provider - [ ] The confirmation names both zones - [ ] Cancelling frees the slot and updates the event - [ ] An expired link is refused with a reason ### Watch out - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ## Not in this build - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. ## After v1, if you want it - Intake questions attached to a booking type, stored as answers rather than as a blob of form fields - A second calendar as a read-only busy source, which is mostly a loop over the same fetch - An availability preview that shows the next two weeks exactly as a booker would see them ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. PostgreSQL with btree_gist installed. The constraint lives here. DATABASE_URL=postgres://localhost:5432/booking # Required. The IANA zone your availability rules are written in. Not an offset. RESOURCE_ZONE=Europe/London # Required. OAuth client for free and busy access. CALENDAR_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=GOCSPX-...redacted # Required. Past this the busy cache counts as no data and the page stops offering slots. BUSY_MAX_AGE_MINUTES=15 # Required · secret. Transactional mail. The outbox worker is the only caller. MAIL_API_KEY=re_...redacted # Required · secret. Signs cancellation and reschedule links. Rotating it invalidates outstanding links. LINK_SIGNING_KEY=a-long-random-string-redacted # Required. How far ahead the page will offer anything. BOOKING_HORIZON_DAYS=60
You are building a production product version of Book Like A Boss. 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 ===== # Book Like A Boss · product brief ## Problem The visible booking storefront loop is buildable, but a credible replacement needs more than the first screen. Book Like A Boss earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page where an overlapping booking is rejected by the database rather than by a race you did not win, availability holds its local time across daylight saving, nothing but busy intervals leaves your calendar, and a failing mail provider never turns into a missed appointment. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. - reliable reminders and timezone edge cases - multi-calendar conflict handling - race-free booking under load - team routing and round robin ## Success criteria - Concurrent inserts are confirmed to leave one booking and one refusal - Slots are confirmed to hold local time across both daylight saving transitions - The busy cache is confirmed to have no column for a title or attendee - A failing mail provider is confirmed to leave a visible stuck row, not a lost message - Stale busy data is confirmed to close the page rather than fall back to rules ===== BRIEF.md ===== # Build brief · Book Like A Boss 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 personal booking page that publishes real availability and cannot double-book you to replace Book Like A Boss. 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. Both are about trust, and a booking page that loses either is not worth running. First: the database prevents the double booking, not the interface. Availability rendered in a browser is a photograph of the past, and the gap between render and submit is long enough for somebody else to take the slot. PostgreSQL can make an overlap impossible, and its own documentation gives the shape: after 'CREATE EXTENSION btree_gist', a table declared with 'EXCLUDE USING GIST (room WITH =, during WITH &&)' rejects overlapping ranges only when the room matches. Checked against the PostgreSQL range types documentation on 15 September 2026. SQLite has nothing equivalent, which is why the database is fixed here and is not a preference. A select-then-insert check is not a substitute. Under the default isolation level two sessions both read an empty result and both insert. Second: only busy intervals leave the connected calendar. Not titles, not attendees, not locations. A stranger looking for a half hour on Thursday has no business learning that you have a hospital appointment on Wednesday, and the free/busy shape of the query is what keeps that accidental leak impossible rather than merely unlikely. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript and server-rendered pages, progressive enhancement only · a booking page is one form and it has to work on a bad phone connection - Database: PostgreSQL 16 or newer with btree_gist installed · it is the only component here that can make an overlapping booking impossible - Times: Instants as timestamptz, rules as local time plus an IANA zone identifier · an offset is a fact about one date, not a rule you can store - Calendar: Free and busy intervals in, events out with an explicit zone identifier · titles and attendees from your calendar are not yours to publish - Mail: One provider, with every message written to an outbox table before the response returns · a confirmation that silently failed is worse than a booking that visibly failed ### Data model (create this before Phase 1) - resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books. ### Phase 1 · Availability computed from rules, not stored as slots Build: Store availability as weekday, local start, local end and an IANA zone. Expand to instants for a requested window using a real time zone library. Subtract exceptions, apply minimum notice and maximum horizon, and render in the booker's zone with the zone named on the page. Trap: Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. Done when: Slots for a week containing a spring transition and a week containing an autumn transition both start at the configured local time, tested against a fixed clock. A rule whose local start does not exist on the spring day is handled by a stated policy rather than throwing, tested. The booker's zone is shown beside every time on the page. No slot appears inside the minimum notice period or beyond the maximum horizon, tested at both boundaries. Do not build yet: No booking, calendar connection, email or cancellation yet. ### Phase 2 · The database refuses the overlap Build: Install btree_gist. Declare the bookings range column with an exclusion constraint on resource equality and range overlap, restricted to confirmed rows so cancellations free the slot. Include the buffers in the stored range. Catch the constraint violation and return a refusal that re-renders current availability rather than a generic error page. Trap: Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. Done when: Two concurrent inserts for the same slot on separate connections yield one confirmed booking and one constraint violation, tested. Cancelling a booking frees the slot immediately for a new booking, tested. Buffers before and after are inside the stored range, so a booking cannot start in another's buffer, tested. The refusal path returns fresh availability and a message naming the slot that went, not a stack trace. Do not build yet: No calendar reading, write-back, email or reschedule yet. ### Phase 3 · Busy in, events out, and nothing else Build: Read free and busy intervals for the resource's calendars and cache them with a fetch time. Never request or store titles, attendees or locations. Write the confirmed booking back as an event with an explicit zone identifier, a stable identifier of your own, and a sequence number you increment on every later change. Store the remote identifier against the booking. Trap: Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. Done when: The busy cache table has no column capable of holding a title, attendee or location, verified by schema review and a test that inspects the columns. A confirmed booking appears in the connected calendar at the right wall clock time in the resource's zone, checked manually once and asserted against the generated payload. Changing a booking increments the sequence and updates the same event rather than creating a second one, tested. Every busy row records when it was fetched. Do not build yet: No email, cancellation links or stale-data policy yet. ### Phase 4 · Confirmations that actually leave, and a way to cancel Build: Write the confirmation into an outbox row inside the same transaction as the booking. A worker sends and marks, retrying with backoff and stopping at a dead state that is visible. Include both the booker's local time and the resource's. Issue a signed cancellation and reschedule link that does not require an account and expires with the booking. Trap: Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. Done when: A booking made while the mail provider is refusing connections still confirms and leaves an outbox row that sends when the provider returns, tested with a failing stub. The confirmation states the time in both zones with the zone names. A cancellation link cancels without an account, frees the slot and updates the calendar event, tested end to end. A link for a booking that already ended is refused with an explanation. Do not build yet: No stale-data policy, export or operational dashboard yet. ### Phase 5 · What the page does when the calendar is down Build: Give the busy cache a maximum age. Past it, stop offering slots and say the calendar is unreachable rather than falling back to rules alone. Show a dashboard of stuck outbox rows, failed calendar writes and cache age. Export bookings and the outbox as CSV. Keep a booking's history so a cancelled slot can still be explained months later. Trap: Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. Done when: With the calendar unreachable and the cache past its maximum age, the page offers no slots and explains why, tested with a stubbed failure and a clock. The dashboard shows cache age, stuck outbox rows and failed calendar writes, each with the time it started failing. Export produces every booking, its state history and its outbox rows, and reimports into an empty database. Deleting a booking is impossible; cancellation keeps the row and its history. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a different availability model and a fairness rule you would have to argue about. - Taking payment at booking time, which brings refunds, disputes and receipts, all of which are bigger than the booking page. - Text message reminders, which cost money per message and need delivery handling of their own before they are worth trusting. - A hosted service for other people's calendars, which makes you responsible for other people's private event data and changes the risk entirely. ### README must contain - That PostgreSQL is fixed because the exclusion constraint is what makes an overlapping booking impossible, with the documentation quoted and the date checked - That a select-then-insert conflict check is not a substitute, and why the concurrency test exists - That availability rules are stored as local time and a zone, with the two daylight saving tests named - That only busy intervals are read from the connected calendar, and that the cache table has no column for a title - That stale busy data closes the booking page instead of falling back to the rules ===== ARCHITECTURE.md ===== # Architecture · Book Like A Boss ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript and server-rendered pages, progressive enhancement only | a booking page is one form and it has to work on a bad phone connection | | Database | PostgreSQL 16 or newer with btree_gist installed | it is the only component here that can make an overlapping booking impossible | | Times | Instants as timestamptz, rules as local time plus an IANA zone identifier | an offset is a fact about one date, not a rule you can store | | Calendar | Free and busy intervals in, events out with an explicit zone identifier | titles and attendees from your calendar are not yours to publish | | Mail | One provider, with every message written to an outbox table before the response returns | a confirmation that silently failed is worse than a booking that visibly failed | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Rule expansion, exceptions, notice and horizon. | Any implementation, provided expansion happens per date in the resource's zone. | | Booking | The exclusion constraint and the refusal path. | Not swappable for an application-level conflict check, which is the failure mode. | | Calendar | Busy reads and event write-back with sequences. | Any provider that can return busy intervals without their contents. | | Outbox | Queued messages, retries and the dead state. | Any queue, provided the enqueue is in the booking transaction. | | Operations | Cache age, stuck work and export. | Any presentation, provided stale busy data still closes the page. | ## 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, secret · PostgreSQL with btree_gist installed. The constraint lives here. - `RESOURCE_ZONE` · required · The IANA zone your availability rules are written in. Not an offset. - `CALENDAR_CLIENT_ID` · required · OAuth client for free and busy access. - `CALENDAR_CLIENT_SECRET` · required, secret · Paired with the client id. Never in the repository. - `BUSY_MAX_AGE_MINUTES` · required · Past this the busy cache counts as no data and the page stops offering slots. - `MAIL_API_KEY` · required, secret · Transactional mail. The outbox worker is the only caller. - `LINK_SIGNING_KEY` · required, secret · Signs cancellation and reschedule links. Rotating it invalidates outstanding links. - `BOOKING_HORIZON_DAYS` · required · How far ahead the page will offer anything. ## 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 · Book Like A Boss product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with TypeScript and server-rendered pages, progressive enhancement only, PostgreSQL 16 or newer with btree_gist installed, Instants as timestamptz, rules as local time plus an IANA zone identifier, Free and busy intervals in, events out with an explicit zone identifier, One provider, with every message written to an outbox table before the response returns. - 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 - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. - Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. ===== MILESTONES.md ===== # Delivery milestones · Book Like A Boss Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability computed from rules, not stored as slots A week of slots is derived from local-time rules and survives a daylight saving change. ### Steps 1. Store rules in local time with a zone identifier An offset is only true until the next transition. Files: `src/db/schema.sql`, `src/availability/rules.ts` 2. Expand per date with a time zone library, never once into UTC The expansion is the part that has to be aware of the transition. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 3. Name the zone beside every displayed time Both sides of a booking should be able to see which clock it means. Files: `src/views/book.ejs` ### Done when - [ ] Slots hold their local start across both transitions - [ ] A non-existent local time has a stated policy and a test - [ ] The booker's zone is displayed with every time - [ ] Notice and horizon bounds are enforced at the edges ### Watch out - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. ## M2 · The database refuses the overlap Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Declare the exclusion constraint and prove it fires This is the single most important line in the schema. Files: `src/db/schema.sql`, `tests/concurrency.test.ts` 2. Put the buffers inside the stored range Otherwise back-to-back bookings leave you no walk between them. Files: `src/booking/create.ts` 3. Turn the violation into a helpful refusal The person is still there and still wants a time. Files: `src/booking/create.ts`, `src/views/taken.ejs` ### Done when - [ ] Concurrent inserts leave one booking and one refusal - [ ] Cancellation frees the slot at once - [ ] Buffers are inside the stored range - [ ] A refusal re-renders availability, not an error ### Watch out - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. ## M3 · Busy in, events out, and nothing else The connected calendar contributes intervals only, and the booking appears in it correctly. ### Steps 1. Request busy intervals, and give the cache nowhere to put a title The schema is the enforcement; the query is only the intent. Files: `src/calendar/freebusy.ts`, `src/db/schema.sql` 2. Write back with an explicit zone identifier and your own stable identifier A floating time in someone else's calendar means a different hour. Files: `src/calendar/write.ts` 3. Increment a sequence on every change It is what makes the second write an update instead of a duplicate. Files: `src/calendar/write.ts`, `tests/sequence.test.ts` ### Done when - [ ] The busy cache cannot hold a title or attendee - [ ] The written event lands at the right local time - [ ] A change updates the event rather than duplicating it - [ ] Every busy row carries a fetch time ### Watch out - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. ## M4 · Confirmations that actually leave, and a way to cancel Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved. ### Steps 1. Queue the message inside the booking transaction Either both happened or neither did. Files: `src/outbox/enqueue.ts`, `src/booking/create.ts` 2. Send from a worker with backoff and a visible dead state A silent retry loop is how a week of confirmations goes missing. Files: `src/outbox/worker.ts`, `tests/outbox.test.ts` 3. Sign the cancellation link and expire it with the booking No account, no guessable identifiers. Files: `src/links.ts`, `tests/links.test.ts` ### Done when - [ ] A booking survives a failing mail provider - [ ] The confirmation names both zones - [ ] Cancelling frees the slot and updates the event - [ ] An expired link is refused with a reason ### Watch out - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ## M5 · What the page does when the calendar is down (production only) Stale information stops the page rather than producing a booking on top of a real meeting. ### Steps 1. Treat stale busy data as no data The graceful fallback is the one that double-books you. Files: `src/calendar/freshness.ts`, `tests/stale.test.ts` 2. Put cache age and stuck rows on one page These are the two failures that are invisible until a client complains. Files: `src/views/ops.ejs` 3. Cancel rather than delete, and export the history Somebody will ask what happened to Thursday. Files: `src/booking/cancel.ts`, `src/export.ts` ### Done when - [ ] Stale busy data closes the page with an explanation - [ ] The dashboard shows cache age and stuck work - [ ] Export round-trips bookings and their history - [ ] Bookings are cancelled, never deleted ### Watch out - Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. ===== OPERATIONS.md ===== # Operations · Book Like A Boss ## Backup Nightly database dump, and keep several. Bookings and their history are the irreplaceable part; the busy cache regenerates and the outbox should be empty most of the time. ## Restore Restore, verify the btree_gist extension exists and the exclusion constraint is present before accepting a single booking, then let the busy cache refill before opening the page. A restore that lost the constraint will accept overlaps silently. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Busy cache age, outbox rows older than a few minutes, failed calendar writes, and the count of constraint refusals. A rising refusal count means the page is showing slots that are already gone, which points at the cache rather than at the constraint. ## Incident checklist The incident is a double booking or a missed appointment, and both are social rather than technical by the time you hear about them. The booking history, the outbox rows and the calendar write sequence together reconstruct what the client saw and what was actually sent. 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 inserts are confirmed to leave one booking and one refusal - [ ] Slots are confirmed to hold local time across both daylight saving transitions - [ ] The busy cache is confirmed to have no column for a title or attendee - [ ] A failing mail provider is confirmed to leave a visible stuck row, not a lost message - [ ] Stale busy data is confirmed to close the page rather than fall back to rules ## Launch constraint Do not market omitted Book Like A Boss 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 · secret. PostgreSQL with btree_gist installed. The constraint lives here. DATABASE_URL=postgres://localhost:5432/booking # Required. The IANA zone your availability rules are written in. Not an offset. RESOURCE_ZONE=Europe/London # Required. OAuth client for free and busy access. CALENDAR_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=GOCSPX-...redacted # Required. Past this the busy cache counts as no data and the page stops offering slots. BUSY_MAX_AGE_MINUTES=15 # Required · secret. Transactional mail. The outbox worker is the only caller. MAIL_API_KEY=re_...redacted # Required · secret. Signs cancellation and reschedule links. Rotating it invalidates outstanding links. LINK_SIGNING_KEY=a-long-random-string-redacted # Required. How far ahead the page will offer anything. BOOKING_HORIZON_DAYS=60
# Book Like A Boss · indie build A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. Estimated effort: **weekend**. 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 | Node with TypeScript and server-rendered pages, progressive enhancement only | a booking page is one form and it has to work on a bad phone connection | | Database | PostgreSQL 16 or newer with btree_gist installed | it is the only component here that can make an overlapping booking impossible | | Times | Instants as timestamptz, rules as local time plus an IANA zone identifier | an offset is a fact about one date, not a rule you can store | | Calendar | Free and busy intervals in, events out with an explicit zone identifier | titles and attendees from your calendar are not yours to publish | | Mail | One provider, with every message written to an outbox table before the response returns | a confirmation that silently failed is worse than a booking that visibly failed | ## 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 application and the outbox worker. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer with btree_gist available** · free locally - Why: The exclusion constraint is the whole double-booking defence and no other database in reach offers it. - Get it: Install locally or use a managed instance, then run CREATE EXTENSION btree_gist. - Verify: The extension creates and a deliberately overlapping insert is rejected - [ ] **A calendar account with free and busy access** · free - Why: Availability that ignores your real calendar is the failure this build exists to prevent. - Get it: Create OAuth credentials for your calendar provider with the narrowest scope that returns busy intervals. - Verify: A busy interval you created by hand appears in the cache with no title - [ ] **A transactional mail provider key** · free tier is usually enough for a personal page - Why: Confirmations are the part clients notice, and a provider that silently drops them is indistinguishable from a broken booking. - Get it: Any provider with an API key and a verified sender address. - Verify: A queued outbox row is sent and marked, and a failing key leaves it visibly stuck - [ ] **Your policy for non-existent and ambiguous local times** · free - Why: Two days a year a rule's local start either does not happen or happens twice, and deciding this at the moment it breaks means deciding it badly. - Get it: Write down what the app does on each, and turn both into tests. - Verify: Both tests exist and the README states the policy ## 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: - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. - reliable reminders and timezone edge cases - multi-calendar conflict handling - race-free booking under load - team routing and round robin If one of those is essential to you, that is the reason to keep paying for Book Like A Boss, and the README should say so rather than pretend.
# Build brief · Book Like A Boss 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 personal booking page that publishes real availability and cannot double-book you to replace Book Like A Boss. 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. Both are about trust, and a booking page that loses either is not worth running. First: the database prevents the double booking, not the interface. Availability rendered in a browser is a photograph of the past, and the gap between render and submit is long enough for somebody else to take the slot. PostgreSQL can make an overlap impossible, and its own documentation gives the shape: after 'CREATE EXTENSION btree_gist', a table declared with 'EXCLUDE USING GIST (room WITH =, during WITH &&)' rejects overlapping ranges only when the room matches. Checked against the PostgreSQL range types documentation on 15 September 2026. SQLite has nothing equivalent, which is why the database is fixed here and is not a preference. A select-then-insert check is not a substitute. Under the default isolation level two sessions both read an empty result and both insert. Second: only busy intervals leave the connected calendar. Not titles, not attendees, not locations. A stranger looking for a half hour on Thursday has no business learning that you have a hospital appointment on Wednesday, and the free/busy shape of the query is what keeps that accidental leak impossible rather than merely unlikely. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript and server-rendered pages, progressive enhancement only · a booking page is one form and it has to work on a bad phone connection - Database: PostgreSQL 16 or newer with btree_gist installed · it is the only component here that can make an overlapping booking impossible - Times: Instants as timestamptz, rules as local time plus an IANA zone identifier · an offset is a fact about one date, not a rule you can store - Calendar: Free and busy intervals in, events out with an explicit zone identifier · titles and attendees from your calendar are not yours to publish - Mail: One provider, with every message written to an outbox table before the response returns · a confirmation that silently failed is worse than a booking that visibly failed ### Data model (create this before Phase 1) - resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books. ### Phase 1 · Availability computed from rules, not stored as slots Build: Store availability as weekday, local start, local end and an IANA zone. Expand to instants for a requested window using a real time zone library. Subtract exceptions, apply minimum notice and maximum horizon, and render in the booker's zone with the zone named on the page. Trap: Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. Done when: Slots for a week containing a spring transition and a week containing an autumn transition both start at the configured local time, tested against a fixed clock. A rule whose local start does not exist on the spring day is handled by a stated policy rather than throwing, tested. The booker's zone is shown beside every time on the page. No slot appears inside the minimum notice period or beyond the maximum horizon, tested at both boundaries. Do not build yet: No booking, calendar connection, email or cancellation yet. ### Phase 2 · The database refuses the overlap Build: Install btree_gist. Declare the bookings range column with an exclusion constraint on resource equality and range overlap, restricted to confirmed rows so cancellations free the slot. Include the buffers in the stored range. Catch the constraint violation and return a refusal that re-renders current availability rather than a generic error page. Trap: Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. Done when: Two concurrent inserts for the same slot on separate connections yield one confirmed booking and one constraint violation, tested. Cancelling a booking frees the slot immediately for a new booking, tested. Buffers before and after are inside the stored range, so a booking cannot start in another's buffer, tested. The refusal path returns fresh availability and a message naming the slot that went, not a stack trace. Do not build yet: No calendar reading, write-back, email or reschedule yet. ### Phase 3 · Busy in, events out, and nothing else Build: Read free and busy intervals for the resource's calendars and cache them with a fetch time. Never request or store titles, attendees or locations. Write the confirmed booking back as an event with an explicit zone identifier, a stable identifier of your own, and a sequence number you increment on every later change. Store the remote identifier against the booking. Trap: Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. Done when: The busy cache table has no column capable of holding a title, attendee or location, verified by schema review and a test that inspects the columns. A confirmed booking appears in the connected calendar at the right wall clock time in the resource's zone, checked manually once and asserted against the generated payload. Changing a booking increments the sequence and updates the same event rather than creating a second one, tested. Every busy row records when it was fetched. Do not build yet: No email, cancellation links or stale-data policy yet. ### Phase 4 · Confirmations that actually leave, and a way to cancel Build: Write the confirmation into an outbox row inside the same transaction as the booking. A worker sends and marks, retrying with backoff and stopping at a dead state that is visible. Include both the booker's local time and the resource's. Issue a signed cancellation and reschedule link that does not require an account and expires with the booking. Trap: Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. Done when: A booking made while the mail provider is refusing connections still confirms and leaves an outbox row that sends when the provider returns, tested with a failing stub. The confirmation states the time in both zones with the zone names. A cancellation link cancels without an account, frees the slot and updates the calendar event, tested end to end. A link for a booking that already ended is refused with an explanation. Do not build yet: No stale-data policy, export or operational dashboard yet. ### Phase 5 · What the page does when the calendar is down Build: Give the busy cache a maximum age. Past it, stop offering slots and say the calendar is unreachable rather than falling back to rules alone. Show a dashboard of stuck outbox rows, failed calendar writes and cache age. Export bookings and the outbox as CSV. Keep a booking's history so a cancelled slot can still be explained months later. Trap: Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. Done when: With the calendar unreachable and the cache past its maximum age, the page offers no slots and explains why, tested with a stubbed failure and a clock. The dashboard shows cache age, stuck outbox rows and failed calendar writes, each with the time it started failing. Export produces every booking, its state history and its outbox rows, and reimports into an empty database. Deleting a booking is impossible; cancellation keeps the row and its history. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a different availability model and a fairness rule you would have to argue about. - Taking payment at booking time, which brings refunds, disputes and receipts, all of which are bigger than the booking page. - Text message reminders, which cost money per message and need delivery handling of their own before they are worth trusting. - A hosted service for other people's calendars, which makes you responsible for other people's private event data and changes the risk entirely. ### README must contain - That PostgreSQL is fixed because the exclusion constraint is what makes an overlapping booking impossible, with the documentation quoted and the date checked - That a select-then-insert conflict check is not a substitute, and why the concurrency test exists - That availability rules are stored as local time and a zone, with the two daylight saving tests named - That only busy intervals are read from the connected calendar, and that the cache table has no column for a title - That stale busy data closes the booking page instead of falling back to the rules
# Agent instructions · Book Like A Boss indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node with TypeScript and server-rendered pages, progressive enhancement only, PostgreSQL 16 or newer with btree_gist installed, Instants as timestamptz, rules as local time plus an IANA zone identifier, Free and busy intervals in, events out with an explicit zone identifier, One provider, with every message written to an outbox table before the response returns. 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 - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones.
# Build plan · Book Like A Boss A personal booking storefront whose hard problems are making an overlapping booking impossible at the storage layer rather than in a form handler, computing availability from local-time rules across daylight saving changes, and publishing nothing from the connected calendar except busy intervals. 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 computed from rules, not stored as slots A week of slots is derived from local-time rules and survives a daylight saving change. ### Steps 1. Store rules in local time with a zone identifier An offset is only true until the next transition. Files: `src/db/schema.sql`, `src/availability/rules.ts` 2. Expand per date with a time zone library, never once into UTC The expansion is the part that has to be aware of the transition. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 3. Name the zone beside every displayed time Both sides of a booking should be able to see which clock it means. Files: `src/views/book.ejs` ### Done when - [ ] Slots hold their local start across both transitions - [ ] A non-existent local time has a stated policy and a test - [ ] The booker's zone is displayed with every time - [ ] Notice and horizon bounds are enforced at the edges ### Watch out - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. ## Phase 2 · The database refuses the overlap Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Declare the exclusion constraint and prove it fires This is the single most important line in the schema. Files: `src/db/schema.sql`, `tests/concurrency.test.ts` 2. Put the buffers inside the stored range Otherwise back-to-back bookings leave you no walk between them. Files: `src/booking/create.ts` 3. Turn the violation into a helpful refusal The person is still there and still wants a time. Files: `src/booking/create.ts`, `src/views/taken.ejs` ### Done when - [ ] Concurrent inserts leave one booking and one refusal - [ ] Cancellation frees the slot at once - [ ] Buffers are inside the stored range - [ ] A refusal re-renders availability, not an error ### Watch out - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. ## Phase 3 · Busy in, events out, and nothing else The connected calendar contributes intervals only, and the booking appears in it correctly. ### Steps 1. Request busy intervals, and give the cache nowhere to put a title The schema is the enforcement; the query is only the intent. Files: `src/calendar/freebusy.ts`, `src/db/schema.sql` 2. Write back with an explicit zone identifier and your own stable identifier A floating time in someone else's calendar means a different hour. Files: `src/calendar/write.ts` 3. Increment a sequence on every change It is what makes the second write an update instead of a duplicate. Files: `src/calendar/write.ts`, `tests/sequence.test.ts` ### Done when - [ ] The busy cache cannot hold a title or attendee - [ ] The written event lands at the right local time - [ ] A change updates the event rather than duplicating it - [ ] Every busy row carries a fetch time ### Watch out - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. ## Phase 4 · Confirmations that actually leave, and a way to cancel Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved. ### Steps 1. Queue the message inside the booking transaction Either both happened or neither did. Files: `src/outbox/enqueue.ts`, `src/booking/create.ts` 2. Send from a worker with backoff and a visible dead state A silent retry loop is how a week of confirmations goes missing. Files: `src/outbox/worker.ts`, `tests/outbox.test.ts` 3. Sign the cancellation link and expire it with the booking No account, no guessable identifiers. Files: `src/links.ts`, `tests/links.test.ts` ### Done when - [ ] A booking survives a failing mail provider - [ ] The confirmation names both zones - [ ] Cancelling frees the slot and updates the event - [ ] An expired link is refused with a reason ### Watch out - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ## Not in this build - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. ## After v1, if you want it - Intake questions attached to a booking type, stored as answers rather than as a blob of form fields - A second calendar as a read-only busy source, which is mostly a loop over the same fetch - An availability preview that shows the next two weeks exactly as a booker would see them
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. PostgreSQL with btree_gist installed. The constraint lives here. DATABASE_URL=postgres://localhost:5432/booking # Required. The IANA zone your availability rules are written in. Not an offset. RESOURCE_ZONE=Europe/London # Required. OAuth client for free and busy access. CALENDAR_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=GOCSPX-...redacted # Required. Past this the busy cache counts as no data and the page stops offering slots. BUSY_MAX_AGE_MINUTES=15 # Required · secret. Transactional mail. The outbox worker is the only caller. MAIL_API_KEY=re_...redacted # Required · secret. Signs cancellation and reschedule links. Rotating it invalidates outstanding links. LINK_SIGNING_KEY=a-long-random-string-redacted # Required. How far ahead the page will offer anything. BOOKING_HORIZON_DAYS=60
# Book Like A Boss · product brief ## Problem The visible booking storefront loop is buildable, but a credible replacement needs more than the first screen. Book Like A Boss earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page where an overlapping booking is rejected by the database rather than by a race you did not win, availability holds its local time across daylight saving, nothing but busy intervals leaves your calendar, and a failing mail provider never turns into a missed appointment. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team routing and round robin. - Payment at booking time. - Text message reminders. - Hosting other people's calendars. - reliable reminders and timezone edge cases - multi-calendar conflict handling - race-free booking under load - team routing and round robin ## Success criteria - Concurrent inserts are confirmed to leave one booking and one refusal - Slots are confirmed to hold local time across both daylight saving transitions - The busy cache is confirmed to have no column for a title or attendee - A failing mail provider is confirmed to leave a visible stuck row, not a lost message - Stale busy data is confirmed to close the page rather than fall back to rules
# Build brief · Book Like A Boss 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 personal booking page that publishes real availability and cannot double-book you to replace Book Like A Boss. 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. Both are about trust, and a booking page that loses either is not worth running. First: the database prevents the double booking, not the interface. Availability rendered in a browser is a photograph of the past, and the gap between render and submit is long enough for somebody else to take the slot. PostgreSQL can make an overlap impossible, and its own documentation gives the shape: after 'CREATE EXTENSION btree_gist', a table declared with 'EXCLUDE USING GIST (room WITH =, during WITH &&)' rejects overlapping ranges only when the room matches. Checked against the PostgreSQL range types documentation on 15 September 2026. SQLite has nothing equivalent, which is why the database is fixed here and is not a preference. A select-then-insert check is not a substitute. Under the default isolation level two sessions both read an empty result and both insert. Second: only busy intervals leave the connected calendar. Not titles, not attendees, not locations. A stranger looking for a half hour on Thursday has no business learning that you have a hospital appointment on Wednesday, and the free/busy shape of the query is what keeps that accidental leak impossible rather than merely unlikely. ### Stack (fixed, do not substitute) - Runtime: Node with TypeScript and server-rendered pages, progressive enhancement only · a booking page is one form and it has to work on a bad phone connection - Database: PostgreSQL 16 or newer with btree_gist installed · it is the only component here that can make an overlapping booking impossible - Times: Instants as timestamptz, rules as local time plus an IANA zone identifier · an offset is a fact about one date, not a rule you can store - Calendar: Free and busy intervals in, events out with an explicit zone identifier · titles and attendees from your calendar are not yours to publish - Mail: One provider, with every message written to an outbox table before the response returns · a confirmation that silently failed is worse than a booking that visibly failed ### Data model (create this before Phase 1) - resources(id, name, zone, min_notice_minutes, max_horizon_days, buffer_before_minutes, buffer_after_minutes) - availability_rules(id, resource_id, weekday, local_start, local_end, zone, effective_from, effective_to) · local clock time plus a zone, never a stored offset - exceptions(id, resource_id, during, kind) · holidays and one-off blocks, held as instants because they are specific - bookings(id, resource_id, during, state, booker_name, booker_email, booker_zone, created_at, cancelled_at, token) · with an exclusion constraint on resource and range overlap, restricted to confirmed rows - busy_cache(resource_id, during, source, fetched_at) · intervals and nothing else, so there is no column a title could ever land in - outbox(id, booking_id, kind, to_email, body, queued_at, sent_at, attempts, last_error) · written inside the booking transaction - calendar_writes(booking_id, provider, remote_id, ical_uid, sequence, state) · the sequence is what turns a second write into an update rather than a duplicate - Nothing in this schema stores a slot. Slots are computed from rules minus busy minus bookings at request time, because a stored slot is wrong the moment somebody else books. ### Phase 1 · Availability computed from rules, not stored as slots Build: Store availability as weekday, local start, local end and an IANA zone. Expand to instants for a requested window using a real time zone library. Subtract exceptions, apply minimum notice and maximum horizon, and render in the booker's zone with the zone named on the page. Trap: Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. Done when: Slots for a week containing a spring transition and a week containing an autumn transition both start at the configured local time, tested against a fixed clock. A rule whose local start does not exist on the spring day is handled by a stated policy rather than throwing, tested. The booker's zone is shown beside every time on the page. No slot appears inside the minimum notice period or beyond the maximum horizon, tested at both boundaries. Do not build yet: No booking, calendar connection, email or cancellation yet. ### Phase 2 · The database refuses the overlap Build: Install btree_gist. Declare the bookings range column with an exclusion constraint on resource equality and range overlap, restricted to confirmed rows so cancellations free the slot. Include the buffers in the stored range. Catch the constraint violation and return a refusal that re-renders current availability rather than a generic error page. Trap: Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. Done when: Two concurrent inserts for the same slot on separate connections yield one confirmed booking and one constraint violation, tested. Cancelling a booking frees the slot immediately for a new booking, tested. Buffers before and after are inside the stored range, so a booking cannot start in another's buffer, tested. The refusal path returns fresh availability and a message naming the slot that went, not a stack trace. Do not build yet: No calendar reading, write-back, email or reschedule yet. ### Phase 3 · Busy in, events out, and nothing else Build: Read free and busy intervals for the resource's calendars and cache them with a fetch time. Never request or store titles, attendees or locations. Write the confirmed booking back as an event with an explicit zone identifier, a stable identifier of your own, and a sequence number you increment on every later change. Store the remote identifier against the booking. Trap: Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. Done when: The busy cache table has no column capable of holding a title, attendee or location, verified by schema review and a test that inspects the columns. A confirmed booking appears in the connected calendar at the right wall clock time in the resource's zone, checked manually once and asserted against the generated payload. Changing a booking increments the sequence and updates the same event rather than creating a second one, tested. Every busy row records when it was fetched. Do not build yet: No email, cancellation links or stale-data policy yet. ### Phase 4 · Confirmations that actually leave, and a way to cancel Build: Write the confirmation into an outbox row inside the same transaction as the booking. A worker sends and marks, retrying with backoff and stopping at a dead state that is visible. Include both the booker's local time and the resource's. Issue a signed cancellation and reschedule link that does not require an account and expires with the booking. Trap: Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. Done when: A booking made while the mail provider is refusing connections still confirms and leaves an outbox row that sends when the provider returns, tested with a failing stub. The confirmation states the time in both zones with the zone names. A cancellation link cancels without an account, frees the slot and updates the calendar event, tested end to end. A link for a booking that already ended is refused with an explanation. Do not build yet: No stale-data policy, export or operational dashboard yet. ### Phase 5 · What the page does when the calendar is down Build: Give the busy cache a maximum age. Past it, stop offering slots and say the calendar is unreachable rather than falling back to rules alone. Show a dashboard of stuck outbox rows, failed calendar writes and cache age. Export bookings and the outbox as CSV. Keep a booking's history so a cancelled slot can still be explained months later. Trap: Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it. Done when: With the calendar unreachable and the cache past its maximum age, the page offers no slots and explains why, tested with a stubbed failure and a clock. The dashboard shows cache age, stuck outbox rows and failed calendar writes, each with the time it started failing. Export produces every booking, its state history and its outbox rows, and reimports into an empty database. Deleting a booking is impossible; cancellation keeps the row and its history. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a different availability model and a fairness rule you would have to argue about. - Taking payment at booking time, which brings refunds, disputes and receipts, all of which are bigger than the booking page. - Text message reminders, which cost money per message and need delivery handling of their own before they are worth trusting. - A hosted service for other people's calendars, which makes you responsible for other people's private event data and changes the risk entirely. ### README must contain - That PostgreSQL is fixed because the exclusion constraint is what makes an overlapping booking impossible, with the documentation quoted and the date checked - That a select-then-insert conflict check is not a substitute, and why the concurrency test exists - That availability rules are stored as local time and a zone, with the two daylight saving tests named - That only busy intervals are read from the connected calendar, and that the cache table has no column for a title - That stale busy data closes the booking page instead of falling back to the rules
# Architecture · Book Like A Boss ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node with TypeScript and server-rendered pages, progressive enhancement only | a booking page is one form and it has to work on a bad phone connection | | Database | PostgreSQL 16 or newer with btree_gist installed | it is the only component here that can make an overlapping booking impossible | | Times | Instants as timestamptz, rules as local time plus an IANA zone identifier | an offset is a fact about one date, not a rule you can store | | Calendar | Free and busy intervals in, events out with an explicit zone identifier | titles and attendees from your calendar are not yours to publish | | Mail | One provider, with every message written to an outbox table before the response returns | a confirmation that silently failed is worse than a booking that visibly failed | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | Rule expansion, exceptions, notice and horizon. | Any implementation, provided expansion happens per date in the resource's zone. | | Booking | The exclusion constraint and the refusal path. | Not swappable for an application-level conflict check, which is the failure mode. | | Calendar | Busy reads and event write-back with sequences. | Any provider that can return busy intervals without their contents. | | Outbox | Queued messages, retries and the dead state. | Any queue, provided the enqueue is in the booking transaction. | | Operations | Cache age, stuck work and export. | Any presentation, provided stale busy data still closes the page. | ## 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, secret · PostgreSQL with btree_gist installed. The constraint lives here. - `RESOURCE_ZONE` · required · The IANA zone your availability rules are written in. Not an offset. - `CALENDAR_CLIENT_ID` · required · OAuth client for free and busy access. - `CALENDAR_CLIENT_SECRET` · required, secret · Paired with the client id. Never in the repository. - `BUSY_MAX_AGE_MINUTES` · required · Past this the busy cache counts as no data and the page stops offering slots. - `MAIL_API_KEY` · required, secret · Transactional mail. The outbox worker is the only caller. - `LINK_SIGNING_KEY` · required, secret · Signs cancellation and reschedule links. Rotating it invalidates outstanding links. - `BOOKING_HORIZON_DAYS` · required · How far ahead the page will offer anything. ## 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 · Book Like A Boss product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node with TypeScript and server-rendered pages, progressive enhancement only, PostgreSQL 16 or newer with btree_gist installed, Instants as timestamptz, rules as local time plus an IANA zone identifier, Free and busy intervals in, events out with an explicit zone identifier, One provider, with every message written to an outbox table before the response returns. - 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 - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. - Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it.
# Delivery milestones · Book Like A Boss Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability computed from rules, not stored as slots A week of slots is derived from local-time rules and survives a daylight saving change. ### Steps 1. Store rules in local time with a zone identifier An offset is only true until the next transition. Files: `src/db/schema.sql`, `src/availability/rules.ts` 2. Expand per date with a time zone library, never once into UTC The expansion is the part that has to be aware of the transition. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 3. Name the zone beside every displayed time Both sides of a booking should be able to see which clock it means. Files: `src/views/book.ejs` ### Done when - [ ] Slots hold their local start across both transitions - [ ] A non-existent local time has a stated policy and a test - [ ] The booker's zone is displayed with every time - [ ] Notice and horizon bounds are enforced at the edges ### Watch out - Expanding a weekly rule once into UTC instants and reusing them shifts every slot by an hour on the two days a year the offset changes, so the page offers eight in the morning in winter and nine in summer and the first person to notice is a client who arrives an hour early. Expand rules to instants per requested date in the resource's zone, and put a test on a week containing each transition. ## M2 · The database refuses the overlap Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Declare the exclusion constraint and prove it fires This is the single most important line in the schema. Files: `src/db/schema.sql`, `tests/concurrency.test.ts` 2. Put the buffers inside the stored range Otherwise back-to-back bookings leave you no walk between them. Files: `src/booking/create.ts` 3. Turn the violation into a helpful refusal The person is still there and still wants a time. Files: `src/booking/create.ts`, `src/views/taken.ejs` ### Done when - [ ] Concurrent inserts leave one booking and one refusal - [ ] Cancellation frees the slot at once - [ ] Buffers are inside the stored range - [ ] A refusal re-renders availability, not an error ### Watch out - Checking for a conflicting booking with a select and then inserting passes every test you write, because your tests run one request at a time, and fails in production at exactly the moment two people want the same popular slot. Let the constraint be the check, and write the concurrency test that fires both inserts from two connections and asserts one succeeded and one was refused. ## M3 · Busy in, events out, and nothing else The connected calendar contributes intervals only, and the booking appears in it correctly. ### Steps 1. Request busy intervals, and give the cache nowhere to put a title The schema is the enforcement; the query is only the intent. Files: `src/calendar/freebusy.ts`, `src/db/schema.sql` 2. Write back with an explicit zone identifier and your own stable identifier A floating time in someone else's calendar means a different hour. Files: `src/calendar/write.ts` 3. Increment a sequence on every change It is what makes the second write an update instead of a duplicate. Files: `src/calendar/write.ts`, `tests/sequence.test.ts` ### Done when - [ ] The busy cache cannot hold a title or attendee - [ ] The written event lands at the right local time - [ ] A change updates the event rather than duplicating it - [ ] Every busy row carries a fetch time ### Watch out - Fetching full events because the endpoint is easier puts other people's meeting titles in your database and one careless template away from a public page, and no later redaction gets them back out of the backups. Ask for busy intervals only, give the cache table no column a title could land in, and assert in a test that the stored rows contain nothing but times. ## M4 · Confirmations that actually leave, and a way to cancel Every booking produces mail that is either sent or visibly stuck, and can be cancelled or moved. ### Steps 1. Queue the message inside the booking transaction Either both happened or neither did. Files: `src/outbox/enqueue.ts`, `src/booking/create.ts` 2. Send from a worker with backoff and a visible dead state A silent retry loop is how a week of confirmations goes missing. Files: `src/outbox/worker.ts`, `tests/outbox.test.ts` 3. Sign the cancellation link and expire it with the booking No account, no guessable identifiers. Files: `src/links.ts`, `tests/links.test.ts` ### Done when - [ ] A booking survives a failing mail provider - [ ] The confirmation names both zones - [ ] Cancelling frees the slot and updates the event - [ ] An expired link is refused with a reason ### Watch out - Calling the mail provider inside the request handler means a slow or failing provider either delays the confirmation page until the browser gives up or loses the message entirely while the booking sits confirmed, and the person shows up on the wrong day because they never got an email. Queue in the same transaction as the booking, send from a worker, and show the stuck ones. ## M5 · What the page does when the calendar is down (production only) Stale information stops the page rather than producing a booking on top of a real meeting. ### Steps 1. Treat stale busy data as no data The graceful fallback is the one that double-books you. Files: `src/calendar/freshness.ts`, `tests/stale.test.ts` 2. Put cache age and stuck rows on one page These are the two failures that are invisible until a client complains. Files: `src/views/ops.ejs` 3. Cancel rather than delete, and export the history Somebody will ask what happened to Thursday. Files: `src/booking/cancel.ts`, `src/export.ts` ### Done when - [ ] Stale busy data closes the page with an explanation - [ ] The dashboard shows cache age and stuck work - [ ] Export round-trips bookings and their history - [ ] Bookings are cancelled, never deleted ### Watch out - Falling back to the availability rules when the calendar connection fails looks graceful and is the exact behaviour that books a client on top of a funeral, because the rules say Thursday morning is free and only the calendar knew otherwise. Treat busy data older than the configured age as no data, close the page with an honest message, and put the cache age on the dashboard where you will see it.
# Operations · Book Like A Boss ## Backup Nightly database dump, and keep several. Bookings and their history are the irreplaceable part; the busy cache regenerates and the outbox should be empty most of the time. ## Restore Restore, verify the btree_gist extension exists and the exclusion constraint is present before accepting a single booking, then let the busy cache refill before opening the page. A restore that lost the constraint will accept overlaps silently. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Busy cache age, outbox rows older than a few minutes, failed calendar writes, and the count of constraint refusals. A rising refusal count means the page is showing slots that are already gone, which points at the cache rather than at the constraint. ## Incident checklist The incident is a double booking or a missed appointment, and both are social rather than technical by the time you hear about them. The booking history, the outbox rows and the calendar write sequence together reconstruct what the client saw and what was actually sent. 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 inserts are confirmed to leave one booking and one refusal - [ ] Slots are confirmed to hold local time across both daylight saving transitions - [ ] The busy cache is confirmed to have no column for a title or attendee - [ ] A failing mail provider is confirmed to leave a visible stuck row, not a lost message - [ ] Stale busy data is confirmed to close the page rather than fall back to rules ## Launch constraint Do not market omitted Book Like A Boss 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 · secret. PostgreSQL with btree_gist installed. The constraint lives here. DATABASE_URL=postgres://localhost:5432/booking # Required. The IANA zone your availability rules are written in. Not an offset. RESOURCE_ZONE=Europe/London # Required. OAuth client for free and busy access. CALENDAR_CLIENT_ID=1234-abcd.apps.googleusercontent.com # Required · secret. Paired with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=GOCSPX-...redacted # Required. Past this the busy cache counts as no data and the page stops offering slots. BUSY_MAX_AGE_MINUTES=15 # Required · secret. Transactional mail. The outbox worker is the only caller. MAIL_API_KEY=re_...redacted # Required · secret. Signs cancellation and reschedule links. Rotating it invalidates outstanding links. LINK_SIGNING_KEY=a-long-random-string-redacted # Required. How far ahead the page will offer anything. BOOKING_HORIZON_DAYS=60
$ choose a build depth, inspect the files, then open the complete pack in your agent
Book Like A Boss: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xreliable reminders and timezone edge cases
xmulti-calendar conflict handling
xrace-free booking under load
xteam routing and round robin
Don't feel like building it? These folks already made it free.
all 4 free alternatives to Book Like A Boss →· no votes, no pay-to-list · just what's real
Book Like A Boss pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| espresso | $10 | $8.33 | Unlimited free bookings/meetings, SMS reminders, group bookings, embeds and integrations |
| mocha | $12 | $10 | Espresso features plus custom domain and removal of Book Like A Boss branding/footer |
| cappuccino | $20 | $16.66 | Paid bookings, coupons, events/classes, recurring appointments and memberships |
| latte | $30 | $25 | All sales features plus custom domain, removed branding and advanced customization |
| teams | — | $8.50/user | Minimum 3 users; team scheduling/admin and 100 SMS credits per seat |
free tierno free tier
billingmonthly + annual for freelancer plans; Teams annual price exposed; 14-day trial, no card; subscriptions auto-renew
hidden costsTeams has a 3-seat minimum. Teams Pro costs an additional $5/seat/month and doubles SMS credits from 100 to 200 per seat while adding white-label/admin customization. Book Like A Boss takes 0% of sales, but Stripe/PayPal processing fees still apply.
verified 2026-08-14 · source ↗
Vibecode Book Like A Boss
Kinda. The core of Book Like A Boss is buildable in a weekend with the prompt on this page, but there are real gaps: reliable reminders and timezone edge cases, multi-calendar conflict handling. Read the honest list above before committing.
How much does Book Like A Boss cost?
Book Like A Boss's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Book Like A Boss?
Honestly: reliable reminders and timezone edge cases; multi-calendar conflict handling; race-free booking under load; team routing and round robin. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Book Like A Boss?
Yes: Appointy Free (One staffer gets reminders, classes and Square checkout for 100 bookings a month; growth wakes the paywall.) Setmore Free (Four staff, payments, classes and 200 monthly bookings; SMS, recurring appointments and two-way sync cost extra.) Square Appointments Free (A serious free solo booking stack where Square operates; processing fees remain and the feature mix varies by country.) All 4 curated free alternatives are at vibecodeit.com/book-like-a-boss/alternatives. The prompt is for when you want it exactly your way.