Vibecode Setmore
track this build4 phases, 12 steps, beginner friendly0%The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Setmore 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 Setmore. 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 ===== # Setmore · indie build A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. 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 | Next.js 15 with TypeScript, the booking page public and everything else behind a password | one public route and one private one is the whole surface | | Storage | SQLite with a unique index on the slot instant, which is the actual booking lock | the constraint is the feature; everything else is presentation | | Calendar | Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy | asking for busy intervals means you never hold an event title in the first place | | Time | Instants stored in UTC, rules stored with the IANA zone they were written in | an availability rule without its zone is meaningless twice a year | | Email | One transactional provider, with a reminders row claimed before sending | the reminder that arrives twice is nearly as bad as the one that never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the sync worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with the narrowest scope that returns busy intervals** · free - Why: Every additional scope is more of your diary exposed to a tool you wrote at the weekend. - Get it: Create a project with your calendar provider, enable the calendar API, and request only the scopes you need to read free and busy and to create events. - Verify: A free and busy query returns intervals and no event content - [ ] **A stable HTTPS address for the OAuth callback and the booking page** · free to a few pounds a month - Why: The consent flow needs a fixed redirect and your visitors need a link that does not change. - Get it: A small host or a tunnel with a stable hostname. - Verify: The consent flow completes and returns to your address - [ ] **A transactional email provider key** · free tiers are usually enough at this volume - Why: Confirmations and reminders go out from here, and a personal mailbox is not the right sender for automated mail. - Get it: Any transactional provider with a verified sending domain. - Verify: A test confirmation arrives with correct times - [ ] **Your availability rules, written down with their timezone** · free - Why: These are the input to everything in phase one, and deciding them in code means deciding them badly. - Get it: Write the weekday windows, slot length, buffers, lead time and horizon into a file before you start. - Verify: The rules file is complete and every rule names a zone ## 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. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Setmore, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Setmore 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 booking page for one calendar that cannot double-book you and never shows a visitor what is in your diary to replace Setmore. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and a booking tool that breaks either one is worse than a shared inbox. The calendar is not a lock. Two visitors looking at the same page at the same moment will both be offered eleven o'clock, and a calendar provider will happily accept both events, because overlapping events are legal and normal. The thing that stops a double booking is a unique constraint in your own database, taken in the same transaction that creates the booking. Not a check followed by an insert, not an in-memory set, not optimism. And the diary stays private. A visitor may learn that you are busy between two instants and nothing else: no title, no attendee, no location, no meeting link. A booking page that pulls full events to compute gaps has already shipped those events to the browser, where they sit in the page source whether or not you rendered them. Query free and busy intervals, store intervals, and let the titles stay where they are. The corollary of both: if the calendar read fails, offer nothing rather than everything. A page that shows a completely free week because a token expired is the failure that fills your Tuesday. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, the booking page public and everything else behind a password · one public route and one private one is the whole surface - Storage: SQLite with a unique index on the slot instant, which is the actual booking lock · the constraint is the feature; everything else is presentation - Calendar: Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy · asking for busy intervals means you never hold an event title in the first place - Time: Instants stored in UTC, rules stored with the IANA zone they were written in · an availability rule without its zone is meaningless twice a year - Email: One transactional provider, with a reminders row claimed before sending · the reminder that arrives twice is nearly as bad as the one that never arrives ### Data model (create this before Phase 1) - calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state) - busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title - rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days) - holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock - bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at) - reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together - A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening. ### Phase 1 · Slots that mean what the clock says Build: Store rules as local wall times with an IANA zone. Generate candidate slots by walking days in that zone and converting each slot boundary to an instant, applying buffers, lead time and horizon. Render in the visitor's detected zone with the zone name printed beside every time. Trap: Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. Done when: Slots on a daylight saving transition day start at the correct wall time in the owner's zone, tested with fixed dates in both directions. Every displayed time carries its zone name, checked in the rendered output. Lead time and horizon are enforced, so a slot twenty minutes from now does not appear when lead time is an hour. Changing the browser zone changes the displayed times and not the underlying instants, tested. Do not build yet: No calendar reading, no holds, no bookings and no email. ### Phase 2 · The hold is the lock Build: Opening the booking form inserts a hold row with a short expiry, under a unique index on the slot instant. Submitting converts hold to booking in one transaction. A second visitor hitting the constraint sees a message telling them the slot just went and a refreshed list, not a stack trace. Trap: Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. Done when: Two concurrent requests for one slot yield exactly one booking, tested by firing both from the suite and asserting the row count. The loser receives a specific message and an updated slot list, not a generic failure. An abandoned hold expires and the slot becomes bookable again without manual cleanup, tested by advancing the clock. A hold that expires while its form is open produces a clear refusal on submit rather than a booking. Do not build yet: No calendar write-back, no reminders and no cancellation yet. ### Phase 3 · Reading the diary without reading the diary Build: Populate busy from the provider's free and busy query over the booking horizon. Keep it current with incremental sync, storing the sync token on the calendar row. Handle the case where the provider rejects the token by wiping the busy table for that calendar and doing a full resync. If the provider is unreachable or the credential is dead, stop offering slots. Trap: Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. Done when: A simulated 410 on the sync call clears the token and triggers a full resync that repopulates busy, tested with a stubbed provider. No column, log line or response body anywhere contains an event title, attendee or location, verified by a test that scans stored rows and by review. A dead or revoked credential puts the calendar into a failed state and the booking page offers no slots with an explanatory message, tested. The time since the last successful sync is visible on the private page. Do not build yet: No reminders, no cancellation links and no export yet. ### Phase 4 · Confirmations and reminders that arrive once Build: On booking, create the calendar event and store its external id on the booking row. Insert reminder rows at their due times, unique per booking and kind. A sender claims a row by setting claimed_at in a conditional update, sends, then records the provider message id. Trap: A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. Done when: A booking creates exactly one calendar event and stores its external id, tested with a stubbed provider. Killing the sender between claim and send leaves the row claimed and unsent, and a restart does not send it again automatically, tested. Cancelling a booking cancels its pending reminders and removes the calendar event. Every email states the time in the recipient's zone with the zone name, checked in the rendered body. Do not build yet: No rescheduling, no export and no data deletion yet. ### Phase 5 · Leaving, cancelling and getting the data out Build: Every confirmation carries a signed link letting the visitor cancel or reschedule without an account, with the token unguessable and single-purpose. Export bookings, holds and reminders as CSV. Provide a deletion command that removes a person's details while keeping the slot history. Document exactly what the busy table holds and what it does not. Trap: Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. Done when: A cancellation link with a modified token is refused, tested by tampering. Rescheduling takes a hold on the new slot before releasing the old one, so a failed reschedule never leaves the visitor with nothing, tested. Export produces CSV for all three tables and reimports into an empty database. Deleting a person's details leaves the booking row with its times and clears name, email and note, tested by inspecting the row. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared staff calendars, which need a second model of who counts as available and are the reason the paid tiers charge per calendar. - Taking payment at booking, which brings a payment processor, refunds and chargebacks into a weekend project. - SMS reminders, which carry per-country registration requirements and per-message cost that email does not. - Group classes and recurring appointments, where the availability model stops being one slot and one person and becomes capacity planning. ### README must contain - That the double booking protection is a unique database constraint, which statement enforces it, and what the loser of a race sees - That the calendar is read as busy intervals only, that no event title is ever fetched or stored, and how to verify that yourself - That a rejected sync token causes a full wipe and resync, and how to tell from the private page that syncing has stalled - That the page fails closed when the calendar cannot be read, and what a visitor sees then - Where the database lives, how to back it up, and what the deletion command does and does not remove ===== AGENTS.md ===== # Agent instructions · Setmore indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, the booking page public and everything else behind a password, SQLite with a unique index on the slot instant, which is the actual booking lock, Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy, Instants stored in UTC, rules stored with the IANA zone they were written in, One transactional provider, with a reminders row claimed before sending. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ===== BUILD_PLAN.md ===== # Build plan · Setmore A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Slots that mean what the clock says Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change. ### Steps 1. Store the zone with the rule, not just the hours Nine to five is not a time until you say where. Files: `src/lib/rules.ts`, `src/db/migrations/001_rules.sql` 2. Generate slots by walking local days Arithmetic on instants is what breaks in October. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Print the zone name next to every time The cheapest fix for the most common support message. Files: `src/app/book/page.tsx` ### Done when - [ ] Slots are correct across both daylight saving transitions - [ ] Every displayed time shows its zone name - [ ] Lead time and horizon are enforced - [ ] Changing browser zone changes display only ### Watch out - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. ## Phase 2 · The hold is the lock Two simultaneous bookings for the same slot produce one confirmation and one clear refusal. ### Steps 1. Make the slot instant unique in the schema This one index is the entire concurrency design. Files: `src/db/migrations/002_holds.sql` 2. Convert hold to booking inside one transaction Two statements with a gap is how double bookings are made. Files: `src/lib/book.ts`, `tests/concurrency.test.ts` 3. Expire holds on a timer and say so on the form An abandoned form should not cost you a Tuesday morning. Files: `src/lib/expire.ts` ### Done when - [ ] Two concurrent bookings produce exactly one row - [ ] The loser gets a specific message and fresh slots - [ ] Expired holds free their slot automatically - [ ] Submitting an expired hold is refused clearly ### Watch out - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. ## Phase 3 · Reading the diary without reading the diary Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected. ### Steps 1. Ask for busy intervals, not events You cannot leak a title you never fetched. Files: `src/lib/calendar/freebusy.ts` 2. Treat a rejected sync token as a full resync The provider documents this as a wipe and start again. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 3. Fail closed when the calendar cannot be read An empty week is not the same as a free week. Files: `src/lib/availability.ts` ### Done when - [ ] A rejected sync token triggers a wipe and full resync - [ ] No event title, attendee or location is ever stored - [ ] A dead credential offers no slots and says why - [ ] Last successful sync time is visible to the owner ### Watch out - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. ## Phase 4 · Confirmations and reminders that arrive once A booking writes an event back to the calendar and sends exactly one confirmation and one reminder. ### Steps 1. Store the external event id on the booking Without it, cancelling leaves a ghost in your calendar. Files: `src/lib/calendar/write.ts` 2. Claim a reminder row before sending it At most once beats at least once when it is somebody's inbox. Files: `src/lib/reminders.ts`, `tests/reminders.test.ts` 3. Put the zone name in every email Half of scheduling support is people in the wrong hour. Files: `src/emails/confirmation.tsx` ### Done when - [ ] One booking creates one event with a stored id - [ ] A crash between claim and send does not double-send - [ ] Cancelling removes the event and pending reminders - [ ] Emails show times with zone names ### Watch out - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ## Not in this build - Team routing and round robin. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. ## After v1, if you want it - A second calendar read only for conflicts, which is one more busy source and no new model - Question fields on the booking form, stored with the booking and included in the export - A waiting list for a slot that frees up, which is genuinely useful and needs nothing beyond the holds table ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Holds, bookings, reminders and busy intervals. Back this up daily. DATABASE_PATH=./data/bookings.db # Required. The OAuth client for the calendar provider. CALENDAR_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=...redacted # Required · secret. Obtained once through consent. When this dies the page must offer no slots. CALENDAR_REFRESH_TOKEN=1//...redacted # Required · secret. Confirmations and reminders. EMAIL_API_KEY=re_...redacted # Required. The zone your availability rules are written in. Never an offset. OWNER_TIMEZONE=Europe/London # Required. How long an open booking form reserves a slot before it expires. HOLD_MINUTES=10 # Required · secret. Guards the private page. The booking page itself is public by design. ADMIN_PASSWORD=a long random string
You are building a lean indie version of Setmore. 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 ===== # Setmore · indie build A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. 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 | Next.js 15 with TypeScript, the booking page public and everything else behind a password | one public route and one private one is the whole surface | | Storage | SQLite with a unique index on the slot instant, which is the actual booking lock | the constraint is the feature; everything else is presentation | | Calendar | Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy | asking for busy intervals means you never hold an event title in the first place | | Time | Instants stored in UTC, rules stored with the IANA zone they were written in | an availability rule without its zone is meaningless twice a year | | Email | One transactional provider, with a reminders row claimed before sending | the reminder that arrives twice is nearly as bad as the one that never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the sync worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with the narrowest scope that returns busy intervals** · free - Why: Every additional scope is more of your diary exposed to a tool you wrote at the weekend. - Get it: Create a project with your calendar provider, enable the calendar API, and request only the scopes you need to read free and busy and to create events. - Verify: A free and busy query returns intervals and no event content - [ ] **A stable HTTPS address for the OAuth callback and the booking page** · free to a few pounds a month - Why: The consent flow needs a fixed redirect and your visitors need a link that does not change. - Get it: A small host or a tunnel with a stable hostname. - Verify: The consent flow completes and returns to your address - [ ] **A transactional email provider key** · free tiers are usually enough at this volume - Why: Confirmations and reminders go out from here, and a personal mailbox is not the right sender for automated mail. - Get it: Any transactional provider with a verified sending domain. - Verify: A test confirmation arrives with correct times - [ ] **Your availability rules, written down with their timezone** · free - Why: These are the input to everything in phase one, and deciding them in code means deciding them badly. - Get it: Write the weekday windows, slot length, buffers, lead time and horizon into a file before you start. - Verify: The rules file is complete and every rule names a zone ## 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. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Setmore, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Setmore 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 booking page for one calendar that cannot double-book you and never shows a visitor what is in your diary to replace Setmore. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and a booking tool that breaks either one is worse than a shared inbox. The calendar is not a lock. Two visitors looking at the same page at the same moment will both be offered eleven o'clock, and a calendar provider will happily accept both events, because overlapping events are legal and normal. The thing that stops a double booking is a unique constraint in your own database, taken in the same transaction that creates the booking. Not a check followed by an insert, not an in-memory set, not optimism. And the diary stays private. A visitor may learn that you are busy between two instants and nothing else: no title, no attendee, no location, no meeting link. A booking page that pulls full events to compute gaps has already shipped those events to the browser, where they sit in the page source whether or not you rendered them. Query free and busy intervals, store intervals, and let the titles stay where they are. The corollary of both: if the calendar read fails, offer nothing rather than everything. A page that shows a completely free week because a token expired is the failure that fills your Tuesday. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, the booking page public and everything else behind a password · one public route and one private one is the whole surface - Storage: SQLite with a unique index on the slot instant, which is the actual booking lock · the constraint is the feature; everything else is presentation - Calendar: Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy · asking for busy intervals means you never hold an event title in the first place - Time: Instants stored in UTC, rules stored with the IANA zone they were written in · an availability rule without its zone is meaningless twice a year - Email: One transactional provider, with a reminders row claimed before sending · the reminder that arrives twice is nearly as bad as the one that never arrives ### Data model (create this before Phase 1) - calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state) - busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title - rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days) - holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock - bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at) - reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together - A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening. ### Phase 1 · Slots that mean what the clock says Build: Store rules as local wall times with an IANA zone. Generate candidate slots by walking days in that zone and converting each slot boundary to an instant, applying buffers, lead time and horizon. Render in the visitor's detected zone with the zone name printed beside every time. Trap: Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. Done when: Slots on a daylight saving transition day start at the correct wall time in the owner's zone, tested with fixed dates in both directions. Every displayed time carries its zone name, checked in the rendered output. Lead time and horizon are enforced, so a slot twenty minutes from now does not appear when lead time is an hour. Changing the browser zone changes the displayed times and not the underlying instants, tested. Do not build yet: No calendar reading, no holds, no bookings and no email. ### Phase 2 · The hold is the lock Build: Opening the booking form inserts a hold row with a short expiry, under a unique index on the slot instant. Submitting converts hold to booking in one transaction. A second visitor hitting the constraint sees a message telling them the slot just went and a refreshed list, not a stack trace. Trap: Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. Done when: Two concurrent requests for one slot yield exactly one booking, tested by firing both from the suite and asserting the row count. The loser receives a specific message and an updated slot list, not a generic failure. An abandoned hold expires and the slot becomes bookable again without manual cleanup, tested by advancing the clock. A hold that expires while its form is open produces a clear refusal on submit rather than a booking. Do not build yet: No calendar write-back, no reminders and no cancellation yet. ### Phase 3 · Reading the diary without reading the diary Build: Populate busy from the provider's free and busy query over the booking horizon. Keep it current with incremental sync, storing the sync token on the calendar row. Handle the case where the provider rejects the token by wiping the busy table for that calendar and doing a full resync. If the provider is unreachable or the credential is dead, stop offering slots. Trap: Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. Done when: A simulated 410 on the sync call clears the token and triggers a full resync that repopulates busy, tested with a stubbed provider. No column, log line or response body anywhere contains an event title, attendee or location, verified by a test that scans stored rows and by review. A dead or revoked credential puts the calendar into a failed state and the booking page offers no slots with an explanatory message, tested. The time since the last successful sync is visible on the private page. Do not build yet: No reminders, no cancellation links and no export yet. ### Phase 4 · Confirmations and reminders that arrive once Build: On booking, create the calendar event and store its external id on the booking row. Insert reminder rows at their due times, unique per booking and kind. A sender claims a row by setting claimed_at in a conditional update, sends, then records the provider message id. Trap: A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. Done when: A booking creates exactly one calendar event and stores its external id, tested with a stubbed provider. Killing the sender between claim and send leaves the row claimed and unsent, and a restart does not send it again automatically, tested. Cancelling a booking cancels its pending reminders and removes the calendar event. Every email states the time in the recipient's zone with the zone name, checked in the rendered body. Do not build yet: No rescheduling, no export and no data deletion yet. ### Phase 5 · Leaving, cancelling and getting the data out Build: Every confirmation carries a signed link letting the visitor cancel or reschedule without an account, with the token unguessable and single-purpose. Export bookings, holds and reminders as CSV. Provide a deletion command that removes a person's details while keeping the slot history. Document exactly what the busy table holds and what it does not. Trap: Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. Done when: A cancellation link with a modified token is refused, tested by tampering. Rescheduling takes a hold on the new slot before releasing the old one, so a failed reschedule never leaves the visitor with nothing, tested. Export produces CSV for all three tables and reimports into an empty database. Deleting a person's details leaves the booking row with its times and clears name, email and note, tested by inspecting the row. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared staff calendars, which need a second model of who counts as available and are the reason the paid tiers charge per calendar. - Taking payment at booking, which brings a payment processor, refunds and chargebacks into a weekend project. - SMS reminders, which carry per-country registration requirements and per-message cost that email does not. - Group classes and recurring appointments, where the availability model stops being one slot and one person and becomes capacity planning. ### README must contain - That the double booking protection is a unique database constraint, which statement enforces it, and what the loser of a race sees - That the calendar is read as busy intervals only, that no event title is ever fetched or stored, and how to verify that yourself - That a rejected sync token causes a full wipe and resync, and how to tell from the private page that syncing has stalled - That the page fails closed when the calendar cannot be read, and what a visitor sees then - Where the database lives, how to back it up, and what the deletion command does and does not remove ===== AGENTS.md ===== # Agent instructions · Setmore indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, the booking page public and everything else behind a password, SQLite with a unique index on the slot instant, which is the actual booking lock, Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy, Instants stored in UTC, rules stored with the IANA zone they were written in, One transactional provider, with a reminders row claimed before sending. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ===== BUILD_PLAN.md ===== # Build plan · Setmore A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Slots that mean what the clock says Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change. ### Steps 1. Store the zone with the rule, not just the hours Nine to five is not a time until you say where. Files: `src/lib/rules.ts`, `src/db/migrations/001_rules.sql` 2. Generate slots by walking local days Arithmetic on instants is what breaks in October. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Print the zone name next to every time The cheapest fix for the most common support message. Files: `src/app/book/page.tsx` ### Done when - [ ] Slots are correct across both daylight saving transitions - [ ] Every displayed time shows its zone name - [ ] Lead time and horizon are enforced - [ ] Changing browser zone changes display only ### Watch out - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. ## Phase 2 · The hold is the lock Two simultaneous bookings for the same slot produce one confirmation and one clear refusal. ### Steps 1. Make the slot instant unique in the schema This one index is the entire concurrency design. Files: `src/db/migrations/002_holds.sql` 2. Convert hold to booking inside one transaction Two statements with a gap is how double bookings are made. Files: `src/lib/book.ts`, `tests/concurrency.test.ts` 3. Expire holds on a timer and say so on the form An abandoned form should not cost you a Tuesday morning. Files: `src/lib/expire.ts` ### Done when - [ ] Two concurrent bookings produce exactly one row - [ ] The loser gets a specific message and fresh slots - [ ] Expired holds free their slot automatically - [ ] Submitting an expired hold is refused clearly ### Watch out - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. ## Phase 3 · Reading the diary without reading the diary Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected. ### Steps 1. Ask for busy intervals, not events You cannot leak a title you never fetched. Files: `src/lib/calendar/freebusy.ts` 2. Treat a rejected sync token as a full resync The provider documents this as a wipe and start again. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 3. Fail closed when the calendar cannot be read An empty week is not the same as a free week. Files: `src/lib/availability.ts` ### Done when - [ ] A rejected sync token triggers a wipe and full resync - [ ] No event title, attendee or location is ever stored - [ ] A dead credential offers no slots and says why - [ ] Last successful sync time is visible to the owner ### Watch out - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. ## Phase 4 · Confirmations and reminders that arrive once A booking writes an event back to the calendar and sends exactly one confirmation and one reminder. ### Steps 1. Store the external event id on the booking Without it, cancelling leaves a ghost in your calendar. Files: `src/lib/calendar/write.ts` 2. Claim a reminder row before sending it At most once beats at least once when it is somebody's inbox. Files: `src/lib/reminders.ts`, `tests/reminders.test.ts` 3. Put the zone name in every email Half of scheduling support is people in the wrong hour. Files: `src/emails/confirmation.tsx` ### Done when - [ ] One booking creates one event with a stored id - [ ] A crash between claim and send does not double-send - [ ] Cancelling removes the event and pending reminders - [ ] Emails show times with zone names ### Watch out - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ## Not in this build - Team routing and round robin. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. ## After v1, if you want it - A second calendar read only for conflicts, which is one more busy source and no new model - Question fields on the booking form, stored with the booking and included in the export - A waiting list for a slot that frees up, which is genuinely useful and needs nothing beyond the holds table ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Holds, bookings, reminders and busy intervals. Back this up daily. DATABASE_PATH=./data/bookings.db # Required. The OAuth client for the calendar provider. CALENDAR_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=...redacted # Required · secret. Obtained once through consent. When this dies the page must offer no slots. CALENDAR_REFRESH_TOKEN=1//...redacted # Required · secret. Confirmations and reminders. EMAIL_API_KEY=re_...redacted # Required. The zone your availability rules are written in. Never an offset. OWNER_TIMEZONE=Europe/London # Required. How long an open booking form reserves a slot before it expires. HOLD_MINUTES=10 # Required · secret. Guards the private page. The booking page itself is public by design. ADMIN_PASSWORD=a long random string
You are building a production product version of Setmore. 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 ===== # Setmore · product brief ## Problem The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Setmore 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 that will not double-book you under concurrent load, never exposes a word of what is in your calendar, offers nothing rather than everything when the calendar cannot be read, and sends each reminder once. ## 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. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - Two concurrent bookings are confirmed to produce one row - No event title, attendee or location is confirmed stored anywhere - A rejected sync token is confirmed to trigger a full resync - A dead credential is confirmed to offer no slots - A tampered cancellation token is confirmed refused ===== BRIEF.md ===== # Build brief · Setmore 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 booking page for one calendar that cannot double-book you and never shows a visitor what is in your diary to replace Setmore. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and a booking tool that breaks either one is worse than a shared inbox. The calendar is not a lock. Two visitors looking at the same page at the same moment will both be offered eleven o'clock, and a calendar provider will happily accept both events, because overlapping events are legal and normal. The thing that stops a double booking is a unique constraint in your own database, taken in the same transaction that creates the booking. Not a check followed by an insert, not an in-memory set, not optimism. And the diary stays private. A visitor may learn that you are busy between two instants and nothing else: no title, no attendee, no location, no meeting link. A booking page that pulls full events to compute gaps has already shipped those events to the browser, where they sit in the page source whether or not you rendered them. Query free and busy intervals, store intervals, and let the titles stay where they are. The corollary of both: if the calendar read fails, offer nothing rather than everything. A page that shows a completely free week because a token expired is the failure that fills your Tuesday. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, the booking page public and everything else behind a password · one public route and one private one is the whole surface - Storage: SQLite with a unique index on the slot instant, which is the actual booking lock · the constraint is the feature; everything else is presentation - Calendar: Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy · asking for busy intervals means you never hold an event title in the first place - Time: Instants stored in UTC, rules stored with the IANA zone they were written in · an availability rule without its zone is meaningless twice a year - Email: One transactional provider, with a reminders row claimed before sending · the reminder that arrives twice is nearly as bad as the one that never arrives ### Data model (create this before Phase 1) - calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state) - busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title - rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days) - holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock - bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at) - reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together - A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening. ### Phase 1 · Slots that mean what the clock says Build: Store rules as local wall times with an IANA zone. Generate candidate slots by walking days in that zone and converting each slot boundary to an instant, applying buffers, lead time and horizon. Render in the visitor's detected zone with the zone name printed beside every time. Trap: Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. Done when: Slots on a daylight saving transition day start at the correct wall time in the owner's zone, tested with fixed dates in both directions. Every displayed time carries its zone name, checked in the rendered output. Lead time and horizon are enforced, so a slot twenty minutes from now does not appear when lead time is an hour. Changing the browser zone changes the displayed times and not the underlying instants, tested. Do not build yet: No calendar reading, no holds, no bookings and no email. ### Phase 2 · The hold is the lock Build: Opening the booking form inserts a hold row with a short expiry, under a unique index on the slot instant. Submitting converts hold to booking in one transaction. A second visitor hitting the constraint sees a message telling them the slot just went and a refreshed list, not a stack trace. Trap: Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. Done when: Two concurrent requests for one slot yield exactly one booking, tested by firing both from the suite and asserting the row count. The loser receives a specific message and an updated slot list, not a generic failure. An abandoned hold expires and the slot becomes bookable again without manual cleanup, tested by advancing the clock. A hold that expires while its form is open produces a clear refusal on submit rather than a booking. Do not build yet: No calendar write-back, no reminders and no cancellation yet. ### Phase 3 · Reading the diary without reading the diary Build: Populate busy from the provider's free and busy query over the booking horizon. Keep it current with incremental sync, storing the sync token on the calendar row. Handle the case where the provider rejects the token by wiping the busy table for that calendar and doing a full resync. If the provider is unreachable or the credential is dead, stop offering slots. Trap: Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. Done when: A simulated 410 on the sync call clears the token and triggers a full resync that repopulates busy, tested with a stubbed provider. No column, log line or response body anywhere contains an event title, attendee or location, verified by a test that scans stored rows and by review. A dead or revoked credential puts the calendar into a failed state and the booking page offers no slots with an explanatory message, tested. The time since the last successful sync is visible on the private page. Do not build yet: No reminders, no cancellation links and no export yet. ### Phase 4 · Confirmations and reminders that arrive once Build: On booking, create the calendar event and store its external id on the booking row. Insert reminder rows at their due times, unique per booking and kind. A sender claims a row by setting claimed_at in a conditional update, sends, then records the provider message id. Trap: A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. Done when: A booking creates exactly one calendar event and stores its external id, tested with a stubbed provider. Killing the sender between claim and send leaves the row claimed and unsent, and a restart does not send it again automatically, tested. Cancelling a booking cancels its pending reminders and removes the calendar event. Every email states the time in the recipient's zone with the zone name, checked in the rendered body. Do not build yet: No rescheduling, no export and no data deletion yet. ### Phase 5 · Leaving, cancelling and getting the data out Build: Every confirmation carries a signed link letting the visitor cancel or reschedule without an account, with the token unguessable and single-purpose. Export bookings, holds and reminders as CSV. Provide a deletion command that removes a person's details while keeping the slot history. Document exactly what the busy table holds and what it does not. Trap: Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. Done when: A cancellation link with a modified token is refused, tested by tampering. Rescheduling takes a hold on the new slot before releasing the old one, so a failed reschedule never leaves the visitor with nothing, tested. Export produces CSV for all three tables and reimports into an empty database. Deleting a person's details leaves the booking row with its times and clears name, email and note, tested by inspecting the row. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared staff calendars, which need a second model of who counts as available and are the reason the paid tiers charge per calendar. - Taking payment at booking, which brings a payment processor, refunds and chargebacks into a weekend project. - SMS reminders, which carry per-country registration requirements and per-message cost that email does not. - Group classes and recurring appointments, where the availability model stops being one slot and one person and becomes capacity planning. ### README must contain - That the double booking protection is a unique database constraint, which statement enforces it, and what the loser of a race sees - That the calendar is read as busy intervals only, that no event title is ever fetched or stored, and how to verify that yourself - That a rejected sync token causes a full wipe and resync, and how to tell from the private page that syncing has stalled - That the page fails closed when the calendar cannot be read, and what a visitor sees then - Where the database lives, how to back it up, and what the deletion command does and does not remove ===== ARCHITECTURE.md ===== # Architecture · Setmore ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, the booking page public and everything else behind a password | one public route and one private one is the whole surface | | Storage | SQLite with a unique index on the slot instant, which is the actual booking lock | the constraint is the feature; everything else is presentation | | Calendar | Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy | asking for busy intervals means you never hold an event title in the first place | | Time | Instants stored in UTC, rules stored with the IANA zone they were written in | an availability rule without its zone is meaningless twice a year | | Email | One transactional provider, with a reminders row claimed before sending | the reminder that arrives twice is nearly as bad as the one that never arrives | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Rules | Availability windows with their zones. | Any representation, provided the zone travels with the hours. | | Holds | The unique slot constraint and expiry. | Not swappable for a check followed by an insert, which is the bug this exists to prevent. | | Calendar | Busy intervals, sync tokens and credential state. | Any provider, provided you fetch intervals rather than events. | | Reminders | Claimed rows, sends and message ids. | Any sender, provided the claim happens before the send. | | Visitor links | Signed cancel and reschedule tokens. | Any scheme, provided the token is unguessable and single-purpose. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Holds, bookings, reminders and busy intervals. Back this up daily. - `CALENDAR_CLIENT_ID` · required · The OAuth client for the calendar provider. - `CALENDAR_CLIENT_SECRET` · required, secret · Pairs with the client id. Never in the repository. - `CALENDAR_REFRESH_TOKEN` · required, secret · Obtained once through consent. When this dies the page must offer no slots. - `EMAIL_API_KEY` · required, secret · Confirmations and reminders. - `OWNER_TIMEZONE` · required · The zone your availability rules are written in. Never an offset. - `HOLD_MINUTES` · required · How long an open booking form reserves a slot before it expires. - `ADMIN_PASSWORD` · required, secret · Guards the private page. The booking page itself is public by design. ## 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 · Setmore product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, the booking page public and everything else behind a password, SQLite with a unique index on the slot instant, which is the actual booking lock, Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy, Instants stored in UTC, rules stored with the IANA zone they were written in, One transactional provider, with a reminders row claimed before sending. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. - Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. ===== MILESTONES.md ===== # Delivery milestones · Setmore Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Slots that mean what the clock says Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change. ### Steps 1. Store the zone with the rule, not just the hours Nine to five is not a time until you say where. Files: `src/lib/rules.ts`, `src/db/migrations/001_rules.sql` 2. Generate slots by walking local days Arithmetic on instants is what breaks in October. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Print the zone name next to every time The cheapest fix for the most common support message. Files: `src/app/book/page.tsx` ### Done when - [ ] Slots are correct across both daylight saving transitions - [ ] Every displayed time shows its zone name - [ ] Lead time and horizon are enforced - [ ] Changing browser zone changes display only ### Watch out - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. ## M2 · The hold is the lock Two simultaneous bookings for the same slot produce one confirmation and one clear refusal. ### Steps 1. Make the slot instant unique in the schema This one index is the entire concurrency design. Files: `src/db/migrations/002_holds.sql` 2. Convert hold to booking inside one transaction Two statements with a gap is how double bookings are made. Files: `src/lib/book.ts`, `tests/concurrency.test.ts` 3. Expire holds on a timer and say so on the form An abandoned form should not cost you a Tuesday morning. Files: `src/lib/expire.ts` ### Done when - [ ] Two concurrent bookings produce exactly one row - [ ] The loser gets a specific message and fresh slots - [ ] Expired holds free their slot automatically - [ ] Submitting an expired hold is refused clearly ### Watch out - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. ## M3 · Reading the diary without reading the diary Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected. ### Steps 1. Ask for busy intervals, not events You cannot leak a title you never fetched. Files: `src/lib/calendar/freebusy.ts` 2. Treat a rejected sync token as a full resync The provider documents this as a wipe and start again. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 3. Fail closed when the calendar cannot be read An empty week is not the same as a free week. Files: `src/lib/availability.ts` ### Done when - [ ] A rejected sync token triggers a wipe and full resync - [ ] No event title, attendee or location is ever stored - [ ] A dead credential offers no slots and says why - [ ] Last successful sync time is visible to the owner ### Watch out - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. ## M4 · Confirmations and reminders that arrive once A booking writes an event back to the calendar and sends exactly one confirmation and one reminder. ### Steps 1. Store the external event id on the booking Without it, cancelling leaves a ghost in your calendar. Files: `src/lib/calendar/write.ts` 2. Claim a reminder row before sending it At most once beats at least once when it is somebody's inbox. Files: `src/lib/reminders.ts`, `tests/reminders.test.ts` 3. Put the zone name in every email Half of scheduling support is people in the wrong hour. Files: `src/emails/confirmation.tsx` ### Done when - [ ] One booking creates one event with a stored id - [ ] A crash between claim and send does not double-send - [ ] Cancelling removes the event and pending reminders - [ ] Emails show times with zone names ### Watch out - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ## M5 · Leaving, cancelling and getting the data out (production only) A visitor can cancel or reschedule from their own link, and you can take everything with you or delete it. ### Steps 1. Use unguessable single-purpose tokens for visitor links A sequential id is an invitation to cancel strangers' appointments. Files: `src/lib/tokens.ts`, `tests/token-tamper.test.ts` 2. Hold the new slot before releasing the old one A reschedule that fails halfway should not lose the appointment. Files: `src/lib/reschedule.ts` 3. Export everything and delete details without losing history Somebody will ask you to forget them and you still need your diary. Files: `src/cli/export.ts`, `src/cli/forget.ts` ### Done when - [ ] A tampered cancellation token is refused - [ ] A failed reschedule leaves the original booking intact - [ ] Export and reimport round-trips every table - [ ] Deleting details keeps the slot history ### Watch out - Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. ===== OPERATIONS.md ===== # Operations · Setmore ## Backup The SQLite file, daily, off the machine. Bookings are the only irreplaceable rows here: busy intervals resync from the provider in a minute and the rules live in a file, but a lost booking is an appointment nobody turns up to. ## Restore Restore the database, then force a full calendar resync rather than trusting the restored sync token, because the token in a backup is older than the calendar. Confirm the booking count and spot-check that upcoming bookings still have valid external event ids. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Two things: minutes since the last successful calendar sync, and the count of reminder rows claimed but never sent. The first tells you whether the page is showing stale availability, and the second is the only way a half-sent reminder batch ever surfaces. ## Incident checklist The incidents here are a double booking and a missed appointment. For the first, the holds table with its constraint violations is the evidence; for the second, the reminders table with claimed and sent timestamps tells you whether the mail ever left. Both are why those columns exist rather than a boolean. 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 - [ ] Two concurrent bookings are confirmed to produce one row - [ ] No event title, attendee or location is confirmed stored anywhere - [ ] A rejected sync token is confirmed to trigger a full resync - [ ] A dead credential is confirmed to offer no slots - [ ] A tampered cancellation token is confirmed refused ## Launch constraint Do not market omitted Setmore 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. Holds, bookings, reminders and busy intervals. Back this up daily. DATABASE_PATH=./data/bookings.db # Required. The OAuth client for the calendar provider. CALENDAR_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=...redacted # Required · secret. Obtained once through consent. When this dies the page must offer no slots. CALENDAR_REFRESH_TOKEN=1//...redacted # Required · secret. Confirmations and reminders. EMAIL_API_KEY=re_...redacted # Required. The zone your availability rules are written in. Never an offset. OWNER_TIMEZONE=Europe/London # Required. How long an open booking form reserves a slot before it expires. HOLD_MINUTES=10 # Required · secret. Guards the private page. The booking page itself is public by design. ADMIN_PASSWORD=a long random string
# Setmore · indie build A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. 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 | Next.js 15 with TypeScript, the booking page public and everything else behind a password | one public route and one private one is the whole surface | | Storage | SQLite with a unique index on the slot instant, which is the actual booking lock | the constraint is the feature; everything else is presentation | | Calendar | Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy | asking for busy intervals means you never hold an event title in the first place | | Time | Instants stored in UTC, rules stored with the IANA zone they were written in | an availability rule without its zone is meaningless twice a year | | Email | One transactional provider, with a reminders row claimed before sending | the reminder that arrives twice is nearly as bad as the one that never arrives | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the sync worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with the narrowest scope that returns busy intervals** · free - Why: Every additional scope is more of your diary exposed to a tool you wrote at the weekend. - Get it: Create a project with your calendar provider, enable the calendar API, and request only the scopes you need to read free and busy and to create events. - Verify: A free and busy query returns intervals and no event content - [ ] **A stable HTTPS address for the OAuth callback and the booking page** · free to a few pounds a month - Why: The consent flow needs a fixed redirect and your visitors need a link that does not change. - Get it: A small host or a tunnel with a stable hostname. - Verify: The consent flow completes and returns to your address - [ ] **A transactional email provider key** · free tiers are usually enough at this volume - Why: Confirmations and reminders go out from here, and a personal mailbox is not the right sender for automated mail. - Get it: Any transactional provider with a verified sending domain. - Verify: A test confirmation arrives with correct times - [ ] **Your availability rules, written down with their timezone** · free - Why: These are the input to everything in phase one, and deciding them in code means deciding them badly. - Get it: Write the weekday windows, slot length, buffers, lead time and horizon into a file before you start. - Verify: The rules file is complete and every rule names a zone ## 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. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for Setmore, and the README should say so rather than pretend.
# Build brief · Setmore 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 booking page for one calendar that cannot double-book you and never shows a visitor what is in your diary to replace Setmore. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and a booking tool that breaks either one is worse than a shared inbox. The calendar is not a lock. Two visitors looking at the same page at the same moment will both be offered eleven o'clock, and a calendar provider will happily accept both events, because overlapping events are legal and normal. The thing that stops a double booking is a unique constraint in your own database, taken in the same transaction that creates the booking. Not a check followed by an insert, not an in-memory set, not optimism. And the diary stays private. A visitor may learn that you are busy between two instants and nothing else: no title, no attendee, no location, no meeting link. A booking page that pulls full events to compute gaps has already shipped those events to the browser, where they sit in the page source whether or not you rendered them. Query free and busy intervals, store intervals, and let the titles stay where they are. The corollary of both: if the calendar read fails, offer nothing rather than everything. A page that shows a completely free week because a token expired is the failure that fills your Tuesday. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, the booking page public and everything else behind a password · one public route and one private one is the whole surface - Storage: SQLite with a unique index on the slot instant, which is the actual booking lock · the constraint is the feature; everything else is presentation - Calendar: Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy · asking for busy intervals means you never hold an event title in the first place - Time: Instants stored in UTC, rules stored with the IANA zone they were written in · an availability rule without its zone is meaningless twice a year - Email: One transactional provider, with a reminders row claimed before sending · the reminder that arrives twice is nearly as bad as the one that never arrives ### Data model (create this before Phase 1) - calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state) - busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title - rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days) - holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock - bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at) - reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together - A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening. ### Phase 1 · Slots that mean what the clock says Build: Store rules as local wall times with an IANA zone. Generate candidate slots by walking days in that zone and converting each slot boundary to an instant, applying buffers, lead time and horizon. Render in the visitor's detected zone with the zone name printed beside every time. Trap: Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. Done when: Slots on a daylight saving transition day start at the correct wall time in the owner's zone, tested with fixed dates in both directions. Every displayed time carries its zone name, checked in the rendered output. Lead time and horizon are enforced, so a slot twenty minutes from now does not appear when lead time is an hour. Changing the browser zone changes the displayed times and not the underlying instants, tested. Do not build yet: No calendar reading, no holds, no bookings and no email. ### Phase 2 · The hold is the lock Build: Opening the booking form inserts a hold row with a short expiry, under a unique index on the slot instant. Submitting converts hold to booking in one transaction. A second visitor hitting the constraint sees a message telling them the slot just went and a refreshed list, not a stack trace. Trap: Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. Done when: Two concurrent requests for one slot yield exactly one booking, tested by firing both from the suite and asserting the row count. The loser receives a specific message and an updated slot list, not a generic failure. An abandoned hold expires and the slot becomes bookable again without manual cleanup, tested by advancing the clock. A hold that expires while its form is open produces a clear refusal on submit rather than a booking. Do not build yet: No calendar write-back, no reminders and no cancellation yet. ### Phase 3 · Reading the diary without reading the diary Build: Populate busy from the provider's free and busy query over the booking horizon. Keep it current with incremental sync, storing the sync token on the calendar row. Handle the case where the provider rejects the token by wiping the busy table for that calendar and doing a full resync. If the provider is unreachable or the credential is dead, stop offering slots. Trap: Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. Done when: A simulated 410 on the sync call clears the token and triggers a full resync that repopulates busy, tested with a stubbed provider. No column, log line or response body anywhere contains an event title, attendee or location, verified by a test that scans stored rows and by review. A dead or revoked credential puts the calendar into a failed state and the booking page offers no slots with an explanatory message, tested. The time since the last successful sync is visible on the private page. Do not build yet: No reminders, no cancellation links and no export yet. ### Phase 4 · Confirmations and reminders that arrive once Build: On booking, create the calendar event and store its external id on the booking row. Insert reminder rows at their due times, unique per booking and kind. A sender claims a row by setting claimed_at in a conditional update, sends, then records the provider message id. Trap: A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. Done when: A booking creates exactly one calendar event and stores its external id, tested with a stubbed provider. Killing the sender between claim and send leaves the row claimed and unsent, and a restart does not send it again automatically, tested. Cancelling a booking cancels its pending reminders and removes the calendar event. Every email states the time in the recipient's zone with the zone name, checked in the rendered body. Do not build yet: No rescheduling, no export and no data deletion yet. ### Phase 5 · Leaving, cancelling and getting the data out Build: Every confirmation carries a signed link letting the visitor cancel or reschedule without an account, with the token unguessable and single-purpose. Export bookings, holds and reminders as CSV. Provide a deletion command that removes a person's details while keeping the slot history. Document exactly what the busy table holds and what it does not. Trap: Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. Done when: A cancellation link with a modified token is refused, tested by tampering. Rescheduling takes a hold on the new slot before releasing the old one, so a failed reschedule never leaves the visitor with nothing, tested. Export produces CSV for all three tables and reimports into an empty database. Deleting a person's details leaves the booking row with its times and clears name, email and note, tested by inspecting the row. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared staff calendars, which need a second model of who counts as available and are the reason the paid tiers charge per calendar. - Taking payment at booking, which brings a payment processor, refunds and chargebacks into a weekend project. - SMS reminders, which carry per-country registration requirements and per-message cost that email does not. - Group classes and recurring appointments, where the availability model stops being one slot and one person and becomes capacity planning. ### README must contain - That the double booking protection is a unique database constraint, which statement enforces it, and what the loser of a race sees - That the calendar is read as busy intervals only, that no event title is ever fetched or stored, and how to verify that yourself - That a rejected sync token causes a full wipe and resync, and how to tell from the private page that syncing has stalled - That the page fails closed when the calendar cannot be read, and what a visitor sees then - Where the database lives, how to back it up, and what the deletion command does and does not remove
# Agent instructions · Setmore indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js 15 with TypeScript, the booking page public and everything else behind a password, SQLite with a unique index on the slot instant, which is the actual booking lock, Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy, Instants stored in UTC, rules stored with the IANA zone they were written in, One transactional provider, with a reminders row claimed before sending. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly.
# Build plan · Setmore A one-calendar booking page whose hard problems are holding a slot atomically so two visitors cannot take it, reading availability without ever fetching event contents, and surviving the day the provider invalidates your sync token. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Slots that mean what the clock says Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change. ### Steps 1. Store the zone with the rule, not just the hours Nine to five is not a time until you say where. Files: `src/lib/rules.ts`, `src/db/migrations/001_rules.sql` 2. Generate slots by walking local days Arithmetic on instants is what breaks in October. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Print the zone name next to every time The cheapest fix for the most common support message. Files: `src/app/book/page.tsx` ### Done when - [ ] Slots are correct across both daylight saving transitions - [ ] Every displayed time shows its zone name - [ ] Lead time and horizon are enforced - [ ] Changing browser zone changes display only ### Watch out - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. ## Phase 2 · The hold is the lock Two simultaneous bookings for the same slot produce one confirmation and one clear refusal. ### Steps 1. Make the slot instant unique in the schema This one index is the entire concurrency design. Files: `src/db/migrations/002_holds.sql` 2. Convert hold to booking inside one transaction Two statements with a gap is how double bookings are made. Files: `src/lib/book.ts`, `tests/concurrency.test.ts` 3. Expire holds on a timer and say so on the form An abandoned form should not cost you a Tuesday morning. Files: `src/lib/expire.ts` ### Done when - [ ] Two concurrent bookings produce exactly one row - [ ] The loser gets a specific message and fresh slots - [ ] Expired holds free their slot automatically - [ ] Submitting an expired hold is refused clearly ### Watch out - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. ## Phase 3 · Reading the diary without reading the diary Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected. ### Steps 1. Ask for busy intervals, not events You cannot leak a title you never fetched. Files: `src/lib/calendar/freebusy.ts` 2. Treat a rejected sync token as a full resync The provider documents this as a wipe and start again. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 3. Fail closed when the calendar cannot be read An empty week is not the same as a free week. Files: `src/lib/availability.ts` ### Done when - [ ] A rejected sync token triggers a wipe and full resync - [ ] No event title, attendee or location is ever stored - [ ] A dead credential offers no slots and says why - [ ] Last successful sync time is visible to the owner ### Watch out - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. ## Phase 4 · Confirmations and reminders that arrive once A booking writes an event back to the calendar and sends exactly one confirmation and one reminder. ### Steps 1. Store the external event id on the booking Without it, cancelling leaves a ghost in your calendar. Files: `src/lib/calendar/write.ts` 2. Claim a reminder row before sending it At most once beats at least once when it is somebody's inbox. Files: `src/lib/reminders.ts`, `tests/reminders.test.ts` 3. Put the zone name in every email Half of scheduling support is people in the wrong hour. Files: `src/emails/confirmation.tsx` ### Done when - [ ] One booking creates one event with a stored id - [ ] A crash between claim and send does not double-send - [ ] Cancelling removes the event and pending reminders - [ ] Emails show times with zone names ### Watch out - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ## Not in this build - Team routing and round robin. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. ## After v1, if you want it - A second calendar read only for conflicts, which is one more busy source and no new model - Question fields on the booking form, stored with the booking and included in the export - A waiting list for a slot that frees up, which is genuinely useful and needs nothing beyond the holds table
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Holds, bookings, reminders and busy intervals. Back this up daily. DATABASE_PATH=./data/bookings.db # Required. The OAuth client for the calendar provider. CALENDAR_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=...redacted # Required · secret. Obtained once through consent. When this dies the page must offer no slots. CALENDAR_REFRESH_TOKEN=1//...redacted # Required · secret. Confirmations and reminders. EMAIL_API_KEY=re_...redacted # Required. The zone your availability rules are written in. Never an offset. OWNER_TIMEZONE=Europe/London # Required. How long an open booking form reserves a slot before it expires. HOLD_MINUTES=10 # Required · secret. Guards the private page. The booking page itself is public by design. ADMIN_PASSWORD=a long random string
# Setmore · product brief ## Problem The visible appointment scheduling loop is buildable, but a credible replacement needs more than the first screen. Setmore 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 that will not double-book you under concurrent load, never exposes a word of what is in your calendar, offers nothing rather than everything when the calendar cannot be read, and sends each reminder once. ## 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. - Payments at booking. - SMS reminders. - Group classes and recurring appointments. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - Two concurrent bookings are confirmed to produce one row - No event title, attendee or location is confirmed stored anywhere - A rejected sync token is confirmed to trigger a full resync - A dead credential is confirmed to offer no slots - A tampered cancellation token is confirmed refused
# Build brief · Setmore 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 booking page for one calendar that cannot double-book you and never shows a visitor what is in your diary to replace Setmore. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and a booking tool that breaks either one is worse than a shared inbox. The calendar is not a lock. Two visitors looking at the same page at the same moment will both be offered eleven o'clock, and a calendar provider will happily accept both events, because overlapping events are legal and normal. The thing that stops a double booking is a unique constraint in your own database, taken in the same transaction that creates the booking. Not a check followed by an insert, not an in-memory set, not optimism. And the diary stays private. A visitor may learn that you are busy between two instants and nothing else: no title, no attendee, no location, no meeting link. A booking page that pulls full events to compute gaps has already shipped those events to the browser, where they sit in the page source whether or not you rendered them. Query free and busy intervals, store intervals, and let the titles stay where they are. The corollary of both: if the calendar read fails, offer nothing rather than everything. A page that shows a completely free week because a token expired is the failure that fills your Tuesday. ### Stack (fixed, do not substitute) - Runtime: Next.js 15 with TypeScript, the booking page public and everything else behind a password · one public route and one private one is the whole surface - Storage: SQLite with a unique index on the slot instant, which is the actual booking lock · the constraint is the feature; everything else is presentation - Calendar: Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy · asking for busy intervals means you never hold an event title in the first place - Time: Instants stored in UTC, rules stored with the IANA zone they were written in · an availability rule without its zone is meaningless twice a year - Email: One transactional provider, with a reminders row claimed before sending · the reminder that arrives twice is nearly as bad as the one that never arrives ### Data model (create this before Phase 1) - calendars(id, provider, external_id, sync_token, last_sync_at, last_full_sync_at, timezone, credential_state) - busy(id, calendar_id, start_utc, end_utc, source, fetched_at) · intervals only, and there is deliberately no column here for a title - rules(id, weekday, start_local, end_local, timezone, slot_minutes, buffer_before, buffer_after, lead_minutes, horizon_days) - holds(id, slot_start_utc, slot_end_utc, created_at, expires_at, booking_id) · unique on slot_start_utc, and this index is the lock - bookings(id, hold_id, name, email, note, state, external_event_id, token, created_at, cancelled_at) - reminders(id, booking_id, kind, due_at, claimed_at, sent_at, provider_message_id) · unique on booking_id and kind together - A hold is created before the booking form is submitted and expires on its own, so an abandoned form frees the slot without anyone intervening. ### Phase 1 · Slots that mean what the clock says Build: Store rules as local wall times with an IANA zone. Generate candidate slots by walking days in that zone and converting each slot boundary to an instant, applying buffers, lead time and horizon. Render in the visitor's detected zone with the zone name printed beside every time. Trap: Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. Done when: Slots on a daylight saving transition day start at the correct wall time in the owner's zone, tested with fixed dates in both directions. Every displayed time carries its zone name, checked in the rendered output. Lead time and horizon are enforced, so a slot twenty minutes from now does not appear when lead time is an hour. Changing the browser zone changes the displayed times and not the underlying instants, tested. Do not build yet: No calendar reading, no holds, no bookings and no email. ### Phase 2 · The hold is the lock Build: Opening the booking form inserts a hold row with a short expiry, under a unique index on the slot instant. Submitting converts hold to booking in one transaction. A second visitor hitting the constraint sees a message telling them the slot just went and a refreshed list, not a stack trace. Trap: Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. Done when: Two concurrent requests for one slot yield exactly one booking, tested by firing both from the suite and asserting the row count. The loser receives a specific message and an updated slot list, not a generic failure. An abandoned hold expires and the slot becomes bookable again without manual cleanup, tested by advancing the clock. A hold that expires while its form is open produces a clear refusal on submit rather than a booking. Do not build yet: No calendar write-back, no reminders and no cancellation yet. ### Phase 3 · Reading the diary without reading the diary Build: Populate busy from the provider's free and busy query over the booking horizon. Keep it current with incremental sync, storing the sync token on the calendar row. Handle the case where the provider rejects the token by wiping the busy table for that calendar and doing a full resync. If the provider is unreachable or the credential is dead, stop offering slots. Trap: Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. Done when: A simulated 410 on the sync call clears the token and triggers a full resync that repopulates busy, tested with a stubbed provider. No column, log line or response body anywhere contains an event title, attendee or location, verified by a test that scans stored rows and by review. A dead or revoked credential puts the calendar into a failed state and the booking page offers no slots with an explanatory message, tested. The time since the last successful sync is visible on the private page. Do not build yet: No reminders, no cancellation links and no export yet. ### Phase 4 · Confirmations and reminders that arrive once Build: On booking, create the calendar event and store its external id on the booking row. Insert reminder rows at their due times, unique per booking and kind. A sender claims a row by setting claimed_at in a conditional update, sends, then records the provider message id. Trap: A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. Done when: A booking creates exactly one calendar event and stores its external id, tested with a stubbed provider. Killing the sender between claim and send leaves the row claimed and unsent, and a restart does not send it again automatically, tested. Cancelling a booking cancels its pending reminders and removes the calendar event. Every email states the time in the recipient's zone with the zone name, checked in the rendered body. Do not build yet: No rescheduling, no export and no data deletion yet. ### Phase 5 · Leaving, cancelling and getting the data out Build: Every confirmation carries a signed link letting the visitor cancel or reschedule without an account, with the token unguessable and single-purpose. Export bookings, holds and reminders as CSV. Provide a deletion command that removes a person's details while keeping the slot history. Document exactly what the busy table holds and what it does not. Trap: Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment. Done when: A cancellation link with a modified token is refused, tested by tampering. Rescheduling takes a hold on the new slot before releasing the old one, so a failed reschedule never leaves the visitor with nothing, tested. Export produces CSV for all three tables and reimports into an empty database. Deleting a person's details leaves the booking row with its times and clears name, email and note, tested by inspecting the row. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared staff calendars, which need a second model of who counts as available and are the reason the paid tiers charge per calendar. - Taking payment at booking, which brings a payment processor, refunds and chargebacks into a weekend project. - SMS reminders, which carry per-country registration requirements and per-message cost that email does not. - Group classes and recurring appointments, where the availability model stops being one slot and one person and becomes capacity planning. ### README must contain - That the double booking protection is a unique database constraint, which statement enforces it, and what the loser of a race sees - That the calendar is read as busy intervals only, that no event title is ever fetched or stored, and how to verify that yourself - That a rejected sync token causes a full wipe and resync, and how to tell from the private page that syncing has stalled - That the page fails closed when the calendar cannot be read, and what a visitor sees then - Where the database lives, how to back it up, and what the deletion command does and does not remove
# Architecture · Setmore ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js 15 with TypeScript, the booking page public and everything else behind a password | one public route and one private one is the whole surface | | Storage | SQLite with a unique index on the slot instant, which is the actual booking lock | the constraint is the feature; everything else is presentation | | Calendar | Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy | asking for busy intervals means you never hold an event title in the first place | | Time | Instants stored in UTC, rules stored with the IANA zone they were written in | an availability rule without its zone is meaningless twice a year | | Email | One transactional provider, with a reminders row claimed before sending | the reminder that arrives twice is nearly as bad as the one that never arrives | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Rules | Availability windows with their zones. | Any representation, provided the zone travels with the hours. | | Holds | The unique slot constraint and expiry. | Not swappable for a check followed by an insert, which is the bug this exists to prevent. | | Calendar | Busy intervals, sync tokens and credential state. | Any provider, provided you fetch intervals rather than events. | | Reminders | Claimed rows, sends and message ids. | Any sender, provided the claim happens before the send. | | Visitor links | Signed cancel and reschedule tokens. | Any scheme, provided the token is unguessable and single-purpose. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Holds, bookings, reminders and busy intervals. Back this up daily. - `CALENDAR_CLIENT_ID` · required · The OAuth client for the calendar provider. - `CALENDAR_CLIENT_SECRET` · required, secret · Pairs with the client id. Never in the repository. - `CALENDAR_REFRESH_TOKEN` · required, secret · Obtained once through consent. When this dies the page must offer no slots. - `EMAIL_API_KEY` · required, secret · Confirmations and reminders. - `OWNER_TIMEZONE` · required · The zone your availability rules are written in. Never an offset. - `HOLD_MINUTES` · required · How long an open booking form reserves a slot before it expires. - `ADMIN_PASSWORD` · required, secret · Guards the private page. The booking page itself is public by design. ## 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 · Setmore product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js 15 with TypeScript, the booking page public and everything else behind a password, SQLite with a unique index on the slot instant, which is the actual booking lock, Read busy intervals through the provider's free and busy query, plus incremental sync for the working copy, Instants stored in UTC, rules stored with the IANA zone they were written in, One transactional provider, with a reminders row claimed before sending. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. - Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment.
# Delivery milestones · Setmore Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Slots that mean what the clock says Availability is generated from rules in the owner's zone and displayed in the visitor's, correctly across a daylight saving change. ### Steps 1. Store the zone with the rule, not just the hours Nine to five is not a time until you say where. Files: `src/lib/rules.ts`, `src/db/migrations/001_rules.sql` 2. Generate slots by walking local days Arithmetic on instants is what breaks in October. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Print the zone name next to every time The cheapest fix for the most common support message. Files: `src/app/book/page.tsx` ### Done when - [ ] Slots are correct across both daylight saving transitions - [ ] Every displayed time shows its zone name - [ ] Lead time and horizon are enforced - [ ] Changing browser zone changes display only ### Watch out - Generating slots by adding a fixed number of minutes to a UTC instant works for fifty weeks and then moves every afternoon appointment by an hour on the two days a year the offset changes, which is precisely when somebody arrives at the wrong time and blames you. Walk the calendar in the owner's local zone and convert each boundary individually. ## M2 · The hold is the lock Two simultaneous bookings for the same slot produce one confirmation and one clear refusal. ### Steps 1. Make the slot instant unique in the schema This one index is the entire concurrency design. Files: `src/db/migrations/002_holds.sql` 2. Convert hold to booking inside one transaction Two statements with a gap is how double bookings are made. Files: `src/lib/book.ts`, `tests/concurrency.test.ts` 3. Expire holds on a timer and say so on the form An abandoned form should not cost you a Tuesday morning. Files: `src/lib/expire.ts` ### Done when - [ ] Two concurrent bookings produce exactly one row - [ ] The loser gets a specific message and fresh slots - [ ] Expired holds free their slot automatically - [ ] Submitting an expired hold is refused clearly ### Watch out - Checking whether a slot is taken and then inserting the booking is two statements with a gap between them, and under exactly the load a popular slot creates, both requests pass the check and both insert. Put the uniqueness in the schema, let the second insert fail, and catch the constraint violation as a normal outcome rather than an error. ## M3 · Reading the diary without reading the diary Busy intervals come from the calendar, stay current through incremental sync, and survive the day the sync token is rejected. ### Steps 1. Ask for busy intervals, not events You cannot leak a title you never fetched. Files: `src/lib/calendar/freebusy.ts` 2. Treat a rejected sync token as a full resync The provider documents this as a wipe and start again. Files: `src/lib/calendar/sync.ts`, `tests/sync-410.test.ts` 3. Fail closed when the calendar cannot be read An empty week is not the same as a free week. Files: `src/lib/availability.ts` ### Done when - [ ] A rejected sync token triggers a wipe and full resync - [ ] No event title, attendee or location is ever stored - [ ] A dead credential offers no slots and says why - [ ] Last successful sync time is visible to the owner ### Watch out - Google's calendar sync guide is explicit that a stale sync token comes back as HTTP 410 Gone and that this should trigger a full wipe of the client's store and a new full sync, so a client that treats 410 as a transient error and retries the same token forever stops receiving updates while continuing to serve a busy table that is quietly weeks out of date. Catch 410 specifically, clear the token and the stored intervals, and resync from scratch. ## M4 · Confirmations and reminders that arrive once A booking writes an event back to the calendar and sends exactly one confirmation and one reminder. ### Steps 1. Store the external event id on the booking Without it, cancelling leaves a ghost in your calendar. Files: `src/lib/calendar/write.ts` 2. Claim a reminder row before sending it At most once beats at least once when it is somebody's inbox. Files: `src/lib/reminders.ts`, `tests/reminders.test.ts` 3. Put the zone name in every email Half of scheduling support is people in the wrong hour. Files: `src/emails/confirmation.tsx` ### Done when - [ ] One booking creates one event with a stored id - [ ] A crash between claim and send does not double-send - [ ] Cancelling removes the event and pending reminders - [ ] Emails show times with zone names ### Watch out - A reminder job that selects everything due, sends, then marks the rows as sent will send the batch again if it dies after the first send and before the update, and the recipient now has two identical emails from a tool whose whole pitch is reliability. Claim each row with a conditional update before the send, and treat a claimed row that never got a message id as something to investigate rather than to retry blindly. ## M5 · Leaving, cancelling and getting the data out (production only) A visitor can cancel or reschedule from their own link, and you can take everything with you or delete it. ### Steps 1. Use unguessable single-purpose tokens for visitor links A sequential id is an invitation to cancel strangers' appointments. Files: `src/lib/tokens.ts`, `tests/token-tamper.test.ts` 2. Hold the new slot before releasing the old one A reschedule that fails halfway should not lose the appointment. Files: `src/lib/reschedule.ts` 3. Export everything and delete details without losing history Somebody will ask you to forget them and you still need your diary. Files: `src/cli/export.ts`, `src/cli/forget.ts` ### Done when - [ ] A tampered cancellation token is refused - [ ] A failed reschedule leaves the original booking intact - [ ] Export and reimport round-trips every table - [ ] Deleting details keeps the slot history ### Watch out - Building cancellation links on a sequential booking id lets anybody walk the range and cancel other people's appointments, and because cancellation looks like normal use you will not notice until somebody complains that their Tuesday vanished. Use a long random token stored on the booking, scoped to that booking and to cancelling, and expire it after the appointment.
# Operations · Setmore ## Backup The SQLite file, daily, off the machine. Bookings are the only irreplaceable rows here: busy intervals resync from the provider in a minute and the rules live in a file, but a lost booking is an appointment nobody turns up to. ## Restore Restore the database, then force a full calendar resync rather than trusting the restored sync token, because the token in a backup is older than the calendar. Confirm the booking count and spot-check that upcoming bookings still have valid external event ids. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Two things: minutes since the last successful calendar sync, and the count of reminder rows claimed but never sent. The first tells you whether the page is showing stale availability, and the second is the only way a half-sent reminder batch ever surfaces. ## Incident checklist The incidents here are a double booking and a missed appointment. For the first, the holds table with its constraint violations is the evidence; for the second, the reminders table with claimed and sent timestamps tells you whether the mail ever left. Both are why those columns exist rather than a boolean. 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 - [ ] Two concurrent bookings are confirmed to produce one row - [ ] No event title, attendee or location is confirmed stored anywhere - [ ] A rejected sync token is confirmed to trigger a full resync - [ ] A dead credential is confirmed to offer no slots - [ ] A tampered cancellation token is confirmed refused ## Launch constraint Do not market omitted Setmore 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. Holds, bookings, reminders and busy intervals. Back this up daily. DATABASE_PATH=./data/bookings.db # Required. The OAuth client for the calendar provider. CALENDAR_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. CALENDAR_CLIENT_SECRET=...redacted # Required · secret. Obtained once through consent. When this dies the page must offer no slots. CALENDAR_REFRESH_TOKEN=1//...redacted # Required · secret. Confirmations and reminders. EMAIL_API_KEY=re_...redacted # Required. The zone your availability rules are written in. Never an offset. OWNER_TIMEZONE=Europe/London # Required. How long an open booking form reserves a slot before it expires. HOLD_MINUTES=10 # Required · secret. Guards the private page. The booking page itself is public by design. ADMIN_PASSWORD=a long random string
$ choose a build depth, inspect the files, then open the complete pack in your agent
Setmore: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
xcalendar-provider edge cases and timezone correctness
xmulti-calendar conflict handling
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
Setmore pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Up to 4 users; 200 appointments/month; 1 reminder per appointment. |
| pro | $12/user | $5/user | Unlimited users and appointments; 2 reminders per appointment; SMS reminders subject to fair use. |
| enterprise | custom | — | Custom booking system for larger organizations; no public numeric seat minimum or quota. |
free tierUp to 4 users; 200 appointments/month; 1 reminder per appointment.
billingmonthly + annual; Pro is $12/user/month monthly or $5/user/month annually; Enterprise custom
hidden costsLive Receptionist is a separate $99/month plan; every staff calendar counts as a paid user even when that staff member has no login. Pro SMS has no fixed numeric allowance, but fair-use monitoring and regional delivery restrictions apply.
verified 2026-08-14 · source ↗
Vibecode Setmore
Kinda. The core of Setmore is buildable in a weekend with the prompt on this page, but there are real gaps: race-free booking under load, team routing and round robin. Read the honest list above before committing.
How much does Setmore cost?
Setmore costs about $12/month (Pro, checked 2026-07-31), which is $144 per year.
What do I lose by replacing Setmore?
Honestly: race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases; calendar-provider edge cases and timezone correctness; multi-calendar conflict handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Setmore?
Yes: Appointy Free (One staffer gets reminders, classes and Square checkout for 100 bookings a month; growth wakes the paywall.) Square Appointments Free (A serious free solo booking stack where Square operates; processing fees remain and the feature mix varies by country.) The prompt is for when you want it exactly your way.