Vibecode Calendbook
track this build3 phases, 9 steps, beginner friendly0%Calendbook's solo core is compact: build a personal booking pages tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability.
You are building a lean indie version of Calendbook. 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 ===== # Calendbook · indie build A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with a small API layer, one process, no background worker | the whole loop is request-shaped and a worker is a service you would have to keep alive | | Storage | SQLite beside the application, with a unique index doing the concurrency work | the database is the only thing that can settle a race, and SQLite can do it here | | Calendar | One Google Calendar connection, free/busy for reading and a deterministic event id for writing | reading busy intervals avoids ever handling other people's event titles | | Time | Every instant stored in UTC with the zone it was chosen in stored beside it | a local time without a zone is a bug that appears twice a year | | Email | One transactional provider, with the calendar attachment generated locally | a confirmation that arrives without an attachment is a meeting nobody records | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud project with the Calendar API enabled and an OAuth client** · free at this volume - Why: Reading free/busy and writing an event both need it, and the consent screen configuration is the step that quietly blocks people for an afternoon. - Get it: Create the project, enable the Calendar API, create an OAuth client with your callback URL, and add yourself as a test user. - Verify: The consent flow completes and a free/busy query returns intervals - [ ] **A stable HTTPS callback URL** · free to a few pounds a month - Why: OAuth will not redirect to a URL that changes every time a tunnel restarts, and this is the most common reason the first connection never completes. - Get it: A small always-on host, or a tunnel with a reserved subdomain. - Verify: The same callback URL works across two restarts - [ ] **A transactional email provider API key** · free at this volume with most providers - Why: Confirmations carry the calendar attachment, and sending them from a home connection means they land in spam. - Get it: Any provider with a free tier; verify the sending domain. - Verify: A confirmation arrives with the attachment intact - [ ] **The organiser's timezone, written down** · free - Why: Availability rules are wall clock in that zone, and the whole daylight saving story depends on there being exactly one answer to this question. - Get it: Write the IANA zone name into configuration, not a fixed offset. - Verify: The configured value is an IANA zone name and the tests use it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. - 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 Calendbook, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Calendbook The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that offers only genuinely free slots, holds one before it is confirmed, and writes the event back exactly once to replace Calendbook. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js with a small API layer, one process, no background worker · the whole loop is request-shaped and a worker is a service you would have to keep alive - Storage: SQLite beside the application, with a unique index doing the concurrency work · the database is the only thing that can settle a race, and SQLite can do it here - Calendar: One Google Calendar connection, free/busy for reading and a deterministic event id for writing · reading busy intervals avoids ever handling other people's event titles - Time: Every instant stored in UTC with the zone it was chosen in stored beside it · a local time without a zone is a bug that appears twice a year - Email: One transactional provider, with the calendar attachment generated locally · a confirmation that arrives without an attachment is a meeting nobody records ### Data model (create this before Phase 1) - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active) - availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five' - busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored - holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form - bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at) - remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one - A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it. ### Phase 1 · Slots that survive the clocks changing Build: Read busy intervals from the calendar's free/busy query, never from the events list, so no title or attendee ever reaches your database. Generate candidate starts by stepping over UTC instants and formatting each into the organiser's zone to test it against the working-hours rules, then subtract busy intervals plus buffers, minimum notice and the booking horizon. Display in the visitor's own zone. Trap: Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. Done when: Slot generation is tested across both daylight saving transitions in a zone that observes them, and produces no duplicated or missing slot. Only the free/busy query is called and no event title, description or attendee appears anywhere in the database, verified by inspection and by a test that fails on those field names. Buffers, minimum notice and the booking horizon each remove the slots they should, tested individually. The visitor's zone is detected and shown, with an explicit control to change it, and the stored instant does not depend on it. Do not build yet: No holds, no bookings, no calendar writes, no email yet. ### Phase 2 · One slot, one person Build: Add a unique index across the event type and start instant that covers live holds and live bookings. Taking a slot inserts a hold with an expiry and a token. Confirming converts the hold to a booking in one transaction. Let the insert fail and turn the constraint violation into a friendly 'that slot just went' response that re-renders the list, rather than checking availability first and hoping. Trap: Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. Done when: Two concurrent confirmations for the same slot produce one booking and one conflict response, tested by firing both inside one test. An expired hold releases the slot and the slot becomes offerable again, tested with a moved clock. The conflict path renders a human message and a refreshed slot list, not a stack trace, checked in the end-to-end test. A cancelled booking frees its slot and the index permits a new booking at that instant, tested. Do not build yet: No calendar write, no email, no reschedule yet. ### Phase 3 · A calendar write that a timeout cannot duplicate Build: Supply your own event identifier on creation rather than letting the API allocate one. Google's reference for events.insert says the characters allowed are those used in base32hex encoding, the lowercase letters a to v and the digits 0 to 9, with a length between 5 and 1024, and that the identifier must be unique per calendar. Derive it deterministically from the booking so a retry addresses the same event. Trap: Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. Done when: The event identifier is derived from the booking identifier, contains only lowercase a to v and digits, and is between 5 and 1024 characters, asserted in a unit test including a case that would otherwise emit characters outside the alphabet. A retried creation after a simulated timeout results in one event, tested against a stub that reports the identifier already exists. The confirmation email carries a calendar attachment that imports into a calendar client with the right start, duration and zone, checked by hand once and by a parser in the suite. Every attempt is recorded in remote_writes with its status. Do not build yet: No reschedule, no cancellation, no quota handling yet. ### Phase 4 · Quota, expiry and the page a stranger sees Build: Handle rate limiting with exponential backoff plus a random component, which is what Google's quota guidance describes, and cache busy intervals for a short stated interval so a page refresh is not a call. When the refresh token stops working, put the page into a state that says bookings are paused rather than offering slots it cannot honour. Add reschedule and cancel links, and export every table as CSV. Trap: A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. Done when: A rate limit response triggers exponential backoff with a random component and the page serves cached intervals with their age shown, tested with a stub returning the limit error. A revoked refresh token puts the page into a paused state that offers no slots and tells the organiser, tested. Reschedule and cancel links are single-use tokens that expire, and a cancelled booking updates the calendar event and frees the slot, tested. Export produces event types, availability, bookings and write attempts as CSV and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a permissions model and a second calendar connection and are where this stops being a one-person tool. - Taking payment at booking, because a booking that is also a purchase brings refunds, disputes and receipts into a weekend project. - Reading full event details from the calendar, since the free/busy query answers the only question you have and keeps other people's meeting titles out of your database entirely. - Automatic reminder sequences, which are a delivery and scheduling problem of their own and need an always-on worker this build deliberately does not have. ### README must contain - That every instant is stored in UTC with the zone it was chosen in, and that both daylight saving transitions are in the test fixture - That double booking is prevented by a unique database index and not by an availability check in the code - That the calendar event identifier is derived from the booking, which is what makes a retried write safe - That only free/busy is read, so no meeting titles or attendees from the organiser's calendar are ever stored - What happens when the refresh token is revoked, and how to reconnect ===== AGENTS.md ===== # Agent instructions · Calendbook indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with a small API layer, one process, no background worker, SQLite beside the application, with a unique index doing the concurrency work, One Google Calendar connection, free/busy for reading and a deterministic event id for writing, Every instant stored in UTC with the zone it was chosen in stored beside it, One transactional provider, with the calendar attachment generated locally. 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 repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ===== BUILD_PLAN.md ===== # Build plan · Calendbook A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. 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 survive the clocks changing The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's. ### Steps 1. Read busy intervals, never events You should not be storing other people's meeting titles at all. Files: `src/lib/freebusy.ts`, `tests/no-event-detail.test.ts` 2. Generate over UTC instants, apply rules in the zone The two transitions are the only interesting test cases here. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Show the visitor's zone and let them change it The stored instant must not move when they do. Files: `src/app/[slug]/page.tsx` ### Done when - [ ] Slot generation is correct across both DST transitions - [ ] No event titles or attendees are ever stored - [ ] Buffers, notice and horizon each remove the right slots - [ ] Changing the displayed zone does not move the instant ### Watch out - Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. ## Phase 2 · One slot, one person Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the uniqueness in the index, not the code Application-level checks lose every race they are given. Files: `migrations/002_unique_slot.sql`, `tests/concurrent-book.test.ts` 2. Hold first, confirm in one transaction The hold is what stops a slot vanishing while the form is being filled in. Files: `src/lib/holds.ts` 3. Make the conflict a designed outcome It will happen, and it needs copy rather than an error page. Files: `src/app/[slug]/confirm/page.tsx` ### Done when - [ ] Concurrent confirmations yield one booking - [ ] An expired hold releases the slot - [ ] The conflict path shows a human message - [ ] Cancelling frees the slot for rebooking ### Watch out - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. ## Phase 3 · A calendar write that a timeout cannot duplicate The event is created once whatever the network does, and both parties receive a confirmation with an attachment. ### Steps 1. Derive the event identifier, do not accept one It is the only idempotency mechanism this API offers you. Files: `src/lib/event-id.ts`, `tests/event-id.test.ts` 2. Treat an already-exists response as success Because it means your first attempt worked and you did not hear. Files: `src/lib/calendar-write.ts`, `tests/retry-insert.test.ts` 3. Generate the calendar attachment locally One fewer dependency, and you control the zone it names. Files: `src/lib/ics.ts`, `tests/ics.test.ts` ### Done when - [ ] The derived identifier fits the documented alphabet and length - [ ] A retried creation produces one event - [ ] The attachment imports with the correct start and zone - [ ] Every write attempt is recorded ### Watch out - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ## Not in this build - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. ## After v1, if you want it - A second calendar connection for conflict checking only, which is a small change once free/busy is already the read path - Custom questions per event type, stored in answers_json and included in the export - A weekly digest of upcoming bookings, which is the one notification that does not need an always-on worker ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Event types, availability, bookings. Back this up; it is the only record of who booked what. DATABASE_PATH=./data/booking.db # Required. OAuth client for the Calendar API. GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com # Required · secret. OAuth client secret. Never in the repository. GOOGLE_CLIENT_SECRET=redacted # Required · secret. Stored after the consent flow. When this stops working the page pauses. GOOGLE_REFRESH_TOKEN=1//redacted # Required. An IANA zone name, never an offset. Availability rules are wall clock in this zone. ORGANISER_TIMEZONE=Europe/London # Required. How long busy intervals are reused. Shorter means more calls against the per-user quota. BUSY_CACHE_SECONDS=120 # Required. How long a slot is held while the visitor fills in the form. HOLD_TTL_SECONDS=600 # Required · secret. Transactional provider for confirmations and cancellations. EMAIL_API_KEY=re_...redacted
You are building a lean indie version of Calendbook. 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 ===== # Calendbook · indie build A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with a small API layer, one process, no background worker | the whole loop is request-shaped and a worker is a service you would have to keep alive | | Storage | SQLite beside the application, with a unique index doing the concurrency work | the database is the only thing that can settle a race, and SQLite can do it here | | Calendar | One Google Calendar connection, free/busy for reading and a deterministic event id for writing | reading busy intervals avoids ever handling other people's event titles | | Time | Every instant stored in UTC with the zone it was chosen in stored beside it | a local time without a zone is a bug that appears twice a year | | Email | One transactional provider, with the calendar attachment generated locally | a confirmation that arrives without an attachment is a meeting nobody records | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud project with the Calendar API enabled and an OAuth client** · free at this volume - Why: Reading free/busy and writing an event both need it, and the consent screen configuration is the step that quietly blocks people for an afternoon. - Get it: Create the project, enable the Calendar API, create an OAuth client with your callback URL, and add yourself as a test user. - Verify: The consent flow completes and a free/busy query returns intervals - [ ] **A stable HTTPS callback URL** · free to a few pounds a month - Why: OAuth will not redirect to a URL that changes every time a tunnel restarts, and this is the most common reason the first connection never completes. - Get it: A small always-on host, or a tunnel with a reserved subdomain. - Verify: The same callback URL works across two restarts - [ ] **A transactional email provider API key** · free at this volume with most providers - Why: Confirmations carry the calendar attachment, and sending them from a home connection means they land in spam. - Get it: Any provider with a free tier; verify the sending domain. - Verify: A confirmation arrives with the attachment intact - [ ] **The organiser's timezone, written down** · free - Why: Availability rules are wall clock in that zone, and the whole daylight saving story depends on there being exactly one answer to this question. - Get it: Write the IANA zone name into configuration, not a fixed offset. - Verify: The configured value is an IANA zone name and the tests use it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. - 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 Calendbook, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Calendbook The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that offers only genuinely free slots, holds one before it is confirmed, and writes the event back exactly once to replace Calendbook. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js with a small API layer, one process, no background worker · the whole loop is request-shaped and a worker is a service you would have to keep alive - Storage: SQLite beside the application, with a unique index doing the concurrency work · the database is the only thing that can settle a race, and SQLite can do it here - Calendar: One Google Calendar connection, free/busy for reading and a deterministic event id for writing · reading busy intervals avoids ever handling other people's event titles - Time: Every instant stored in UTC with the zone it was chosen in stored beside it · a local time without a zone is a bug that appears twice a year - Email: One transactional provider, with the calendar attachment generated locally · a confirmation that arrives without an attachment is a meeting nobody records ### Data model (create this before Phase 1) - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active) - availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five' - busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored - holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form - bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at) - remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one - A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it. ### Phase 1 · Slots that survive the clocks changing Build: Read busy intervals from the calendar's free/busy query, never from the events list, so no title or attendee ever reaches your database. Generate candidate starts by stepping over UTC instants and formatting each into the organiser's zone to test it against the working-hours rules, then subtract busy intervals plus buffers, minimum notice and the booking horizon. Display in the visitor's own zone. Trap: Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. Done when: Slot generation is tested across both daylight saving transitions in a zone that observes them, and produces no duplicated or missing slot. Only the free/busy query is called and no event title, description or attendee appears anywhere in the database, verified by inspection and by a test that fails on those field names. Buffers, minimum notice and the booking horizon each remove the slots they should, tested individually. The visitor's zone is detected and shown, with an explicit control to change it, and the stored instant does not depend on it. Do not build yet: No holds, no bookings, no calendar writes, no email yet. ### Phase 2 · One slot, one person Build: Add a unique index across the event type and start instant that covers live holds and live bookings. Taking a slot inserts a hold with an expiry and a token. Confirming converts the hold to a booking in one transaction. Let the insert fail and turn the constraint violation into a friendly 'that slot just went' response that re-renders the list, rather than checking availability first and hoping. Trap: Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. Done when: Two concurrent confirmations for the same slot produce one booking and one conflict response, tested by firing both inside one test. An expired hold releases the slot and the slot becomes offerable again, tested with a moved clock. The conflict path renders a human message and a refreshed slot list, not a stack trace, checked in the end-to-end test. A cancelled booking frees its slot and the index permits a new booking at that instant, tested. Do not build yet: No calendar write, no email, no reschedule yet. ### Phase 3 · A calendar write that a timeout cannot duplicate Build: Supply your own event identifier on creation rather than letting the API allocate one. Google's reference for events.insert says the characters allowed are those used in base32hex encoding, the lowercase letters a to v and the digits 0 to 9, with a length between 5 and 1024, and that the identifier must be unique per calendar. Derive it deterministically from the booking so a retry addresses the same event. Trap: Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. Done when: The event identifier is derived from the booking identifier, contains only lowercase a to v and digits, and is between 5 and 1024 characters, asserted in a unit test including a case that would otherwise emit characters outside the alphabet. A retried creation after a simulated timeout results in one event, tested against a stub that reports the identifier already exists. The confirmation email carries a calendar attachment that imports into a calendar client with the right start, duration and zone, checked by hand once and by a parser in the suite. Every attempt is recorded in remote_writes with its status. Do not build yet: No reschedule, no cancellation, no quota handling yet. ### Phase 4 · Quota, expiry and the page a stranger sees Build: Handle rate limiting with exponential backoff plus a random component, which is what Google's quota guidance describes, and cache busy intervals for a short stated interval so a page refresh is not a call. When the refresh token stops working, put the page into a state that says bookings are paused rather than offering slots it cannot honour. Add reschedule and cancel links, and export every table as CSV. Trap: A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. Done when: A rate limit response triggers exponential backoff with a random component and the page serves cached intervals with their age shown, tested with a stub returning the limit error. A revoked refresh token puts the page into a paused state that offers no slots and tells the organiser, tested. Reschedule and cancel links are single-use tokens that expire, and a cancelled booking updates the calendar event and frees the slot, tested. Export produces event types, availability, bookings and write attempts as CSV and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a permissions model and a second calendar connection and are where this stops being a one-person tool. - Taking payment at booking, because a booking that is also a purchase brings refunds, disputes and receipts into a weekend project. - Reading full event details from the calendar, since the free/busy query answers the only question you have and keeps other people's meeting titles out of your database entirely. - Automatic reminder sequences, which are a delivery and scheduling problem of their own and need an always-on worker this build deliberately does not have. ### README must contain - That every instant is stored in UTC with the zone it was chosen in, and that both daylight saving transitions are in the test fixture - That double booking is prevented by a unique database index and not by an availability check in the code - That the calendar event identifier is derived from the booking, which is what makes a retried write safe - That only free/busy is read, so no meeting titles or attendees from the organiser's calendar are ever stored - What happens when the refresh token is revoked, and how to reconnect ===== AGENTS.md ===== # Agent instructions · Calendbook indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with a small API layer, one process, no background worker, SQLite beside the application, with a unique index doing the concurrency work, One Google Calendar connection, free/busy for reading and a deterministic event id for writing, Every instant stored in UTC with the zone it was chosen in stored beside it, One transactional provider, with the calendar attachment generated locally. 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 repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ===== BUILD_PLAN.md ===== # Build plan · Calendbook A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. 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 survive the clocks changing The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's. ### Steps 1. Read busy intervals, never events You should not be storing other people's meeting titles at all. Files: `src/lib/freebusy.ts`, `tests/no-event-detail.test.ts` 2. Generate over UTC instants, apply rules in the zone The two transitions are the only interesting test cases here. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Show the visitor's zone and let them change it The stored instant must not move when they do. Files: `src/app/[slug]/page.tsx` ### Done when - [ ] Slot generation is correct across both DST transitions - [ ] No event titles or attendees are ever stored - [ ] Buffers, notice and horizon each remove the right slots - [ ] Changing the displayed zone does not move the instant ### Watch out - Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. ## Phase 2 · One slot, one person Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the uniqueness in the index, not the code Application-level checks lose every race they are given. Files: `migrations/002_unique_slot.sql`, `tests/concurrent-book.test.ts` 2. Hold first, confirm in one transaction The hold is what stops a slot vanishing while the form is being filled in. Files: `src/lib/holds.ts` 3. Make the conflict a designed outcome It will happen, and it needs copy rather than an error page. Files: `src/app/[slug]/confirm/page.tsx` ### Done when - [ ] Concurrent confirmations yield one booking - [ ] An expired hold releases the slot - [ ] The conflict path shows a human message - [ ] Cancelling frees the slot for rebooking ### Watch out - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. ## Phase 3 · A calendar write that a timeout cannot duplicate The event is created once whatever the network does, and both parties receive a confirmation with an attachment. ### Steps 1. Derive the event identifier, do not accept one It is the only idempotency mechanism this API offers you. Files: `src/lib/event-id.ts`, `tests/event-id.test.ts` 2. Treat an already-exists response as success Because it means your first attempt worked and you did not hear. Files: `src/lib/calendar-write.ts`, `tests/retry-insert.test.ts` 3. Generate the calendar attachment locally One fewer dependency, and you control the zone it names. Files: `src/lib/ics.ts`, `tests/ics.test.ts` ### Done when - [ ] The derived identifier fits the documented alphabet and length - [ ] A retried creation produces one event - [ ] The attachment imports with the correct start and zone - [ ] Every write attempt is recorded ### Watch out - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ## Not in this build - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. ## After v1, if you want it - A second calendar connection for conflict checking only, which is a small change once free/busy is already the read path - Custom questions per event type, stored in answers_json and included in the export - A weekly digest of upcoming bookings, which is the one notification that does not need an always-on worker ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Event types, availability, bookings. Back this up; it is the only record of who booked what. DATABASE_PATH=./data/booking.db # Required. OAuth client for the Calendar API. GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com # Required · secret. OAuth client secret. Never in the repository. GOOGLE_CLIENT_SECRET=redacted # Required · secret. Stored after the consent flow. When this stops working the page pauses. GOOGLE_REFRESH_TOKEN=1//redacted # Required. An IANA zone name, never an offset. Availability rules are wall clock in this zone. ORGANISER_TIMEZONE=Europe/London # Required. How long busy intervals are reused. Shorter means more calls against the per-user quota. BUSY_CACHE_SECONDS=120 # Required. How long a slot is held while the visitor fills in the form. HOLD_TTL_SECONDS=600 # Required · secret. Transactional provider for confirmations and cancellations. EMAIL_API_KEY=re_...redacted
You are building a production product version of Calendbook. 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 ===== # Calendbook · product brief ## Problem Calendbook's solo core is compact: build a personal booking pages tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability. ## Product outcome A booking page one person can trust: slots are correct on the weekend the clocks change, one slot can only be taken once because the database says so, a timed-out calendar write does not create a second meeting, and the page pauses honestly when the connection breaks. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. - 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 - Slot generation is confirmed correct across both DST transitions - Concurrent confirmations are confirmed to yield one booking - A retried calendar write is confirmed to create one event - No event titles or attendees are confirmed stored - A revoked token is confirmed to pause the page rather than offer slots ===== BRIEF.md ===== # Build brief · Calendbook The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that offers only genuinely free slots, holds one before it is confirmed, and writes the event back exactly once to replace Calendbook. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js with a small API layer, one process, no background worker · the whole loop is request-shaped and a worker is a service you would have to keep alive - Storage: SQLite beside the application, with a unique index doing the concurrency work · the database is the only thing that can settle a race, and SQLite can do it here - Calendar: One Google Calendar connection, free/busy for reading and a deterministic event id for writing · reading busy intervals avoids ever handling other people's event titles - Time: Every instant stored in UTC with the zone it was chosen in stored beside it · a local time without a zone is a bug that appears twice a year - Email: One transactional provider, with the calendar attachment generated locally · a confirmation that arrives without an attachment is a meeting nobody records ### Data model (create this before Phase 1) - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active) - availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five' - busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored - holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form - bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at) - remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one - A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it. ### Phase 1 · Slots that survive the clocks changing Build: Read busy intervals from the calendar's free/busy query, never from the events list, so no title or attendee ever reaches your database. Generate candidate starts by stepping over UTC instants and formatting each into the organiser's zone to test it against the working-hours rules, then subtract busy intervals plus buffers, minimum notice and the booking horizon. Display in the visitor's own zone. Trap: Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. Done when: Slot generation is tested across both daylight saving transitions in a zone that observes them, and produces no duplicated or missing slot. Only the free/busy query is called and no event title, description or attendee appears anywhere in the database, verified by inspection and by a test that fails on those field names. Buffers, minimum notice and the booking horizon each remove the slots they should, tested individually. The visitor's zone is detected and shown, with an explicit control to change it, and the stored instant does not depend on it. Do not build yet: No holds, no bookings, no calendar writes, no email yet. ### Phase 2 · One slot, one person Build: Add a unique index across the event type and start instant that covers live holds and live bookings. Taking a slot inserts a hold with an expiry and a token. Confirming converts the hold to a booking in one transaction. Let the insert fail and turn the constraint violation into a friendly 'that slot just went' response that re-renders the list, rather than checking availability first and hoping. Trap: Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. Done when: Two concurrent confirmations for the same slot produce one booking and one conflict response, tested by firing both inside one test. An expired hold releases the slot and the slot becomes offerable again, tested with a moved clock. The conflict path renders a human message and a refreshed slot list, not a stack trace, checked in the end-to-end test. A cancelled booking frees its slot and the index permits a new booking at that instant, tested. Do not build yet: No calendar write, no email, no reschedule yet. ### Phase 3 · A calendar write that a timeout cannot duplicate Build: Supply your own event identifier on creation rather than letting the API allocate one. Google's reference for events.insert says the characters allowed are those used in base32hex encoding, the lowercase letters a to v and the digits 0 to 9, with a length between 5 and 1024, and that the identifier must be unique per calendar. Derive it deterministically from the booking so a retry addresses the same event. Trap: Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. Done when: The event identifier is derived from the booking identifier, contains only lowercase a to v and digits, and is between 5 and 1024 characters, asserted in a unit test including a case that would otherwise emit characters outside the alphabet. A retried creation after a simulated timeout results in one event, tested against a stub that reports the identifier already exists. The confirmation email carries a calendar attachment that imports into a calendar client with the right start, duration and zone, checked by hand once and by a parser in the suite. Every attempt is recorded in remote_writes with its status. Do not build yet: No reschedule, no cancellation, no quota handling yet. ### Phase 4 · Quota, expiry and the page a stranger sees Build: Handle rate limiting with exponential backoff plus a random component, which is what Google's quota guidance describes, and cache busy intervals for a short stated interval so a page refresh is not a call. When the refresh token stops working, put the page into a state that says bookings are paused rather than offering slots it cannot honour. Add reschedule and cancel links, and export every table as CSV. Trap: A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. Done when: A rate limit response triggers exponential backoff with a random component and the page serves cached intervals with their age shown, tested with a stub returning the limit error. A revoked refresh token puts the page into a paused state that offers no slots and tells the organiser, tested. Reschedule and cancel links are single-use tokens that expire, and a cancelled booking updates the calendar event and frees the slot, tested. Export produces event types, availability, bookings and write attempts as CSV and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a permissions model and a second calendar connection and are where this stops being a one-person tool. - Taking payment at booking, because a booking that is also a purchase brings refunds, disputes and receipts into a weekend project. - Reading full event details from the calendar, since the free/busy query answers the only question you have and keeps other people's meeting titles out of your database entirely. - Automatic reminder sequences, which are a delivery and scheduling problem of their own and need an always-on worker this build deliberately does not have. ### README must contain - That every instant is stored in UTC with the zone it was chosen in, and that both daylight saving transitions are in the test fixture - That double booking is prevented by a unique database index and not by an availability check in the code - That the calendar event identifier is derived from the booking, which is what makes a retried write safe - That only free/busy is read, so no meeting titles or attendees from the organiser's calendar are ever stored - What happens when the refresh token is revoked, and how to reconnect ===== ARCHITECTURE.md ===== # Architecture · Calendbook ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with a small API layer, one process, no background worker | the whole loop is request-shaped and a worker is a service you would have to keep alive | | Storage | SQLite beside the application, with a unique index doing the concurrency work | the database is the only thing that can settle a race, and SQLite can do it here | | Calendar | One Google Calendar connection, free/busy for reading and a deterministic event id for writing | reading busy intervals avoids ever handling other people's event titles | | Time | Every instant stored in UTC with the zone it was chosen in stored beside it | a local time without a zone is a bug that appears twice a year | | Email | One transactional provider, with the calendar attachment generated locally | a confirmation that arrives without an attachment is a meeting nobody records | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Slots | Rules, buffers, horizon and instant generation. | Any implementation, provided it iterates instants rather than wall-clock times. | | Holds | Short-lived claims and their expiry. | Any store, provided the unique index covers holds and bookings together. | | Calendar | Free/busy reads and deterministic event writes. | Any provider that lets the client choose an event identifier; without that, the retry story changes. | | Notify | Confirmations, cancellations and the attachment. | Any provider, provided the attachment is generated locally. | | Export | CSV of every table and reimport. | Any format, provided bookings survive the application being deleted. | ## 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 · Event types, availability, bookings. Back this up; it is the only record of who booked what. - `GOOGLE_CLIENT_ID` · required · OAuth client for the Calendar API. - `GOOGLE_CLIENT_SECRET` · required, secret · OAuth client secret. Never in the repository. - `GOOGLE_REFRESH_TOKEN` · required, secret · Stored after the consent flow. When this stops working the page pauses. - `ORGANISER_TIMEZONE` · required · An IANA zone name, never an offset. Availability rules are wall clock in this zone. - `BUSY_CACHE_SECONDS` · required · How long busy intervals are reused. Shorter means more calls against the per-user quota. - `HOLD_TTL_SECONDS` · required · How long a slot is held while the visitor fills in the form. - `EMAIL_API_KEY` · required, secret · Transactional provider for confirmations and cancellations. ## 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 · Calendbook product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with a small API layer, one process, no background worker, SQLite beside the application, with a unique index doing the concurrency work, One Google Calendar connection, free/busy for reading and a deterministic event id for writing, Every instant stored in UTC with the zone it was chosen in stored beside it, One transactional provider, with the calendar attachment generated locally. - 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 repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. - A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. ===== MILESTONES.md ===== # Delivery milestones · Calendbook Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Slots that survive the clocks changing The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's. ### Steps 1. Read busy intervals, never events You should not be storing other people's meeting titles at all. Files: `src/lib/freebusy.ts`, `tests/no-event-detail.test.ts` 2. Generate over UTC instants, apply rules in the zone The two transitions are the only interesting test cases here. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Show the visitor's zone and let them change it The stored instant must not move when they do. Files: `src/app/[slug]/page.tsx` ### Done when - [ ] Slot generation is correct across both DST transitions - [ ] No event titles or attendees are ever stored - [ ] Buffers, notice and horizon each remove the right slots - [ ] Changing the displayed zone does not move the instant ### Watch out - Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. ## M2 · One slot, one person Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the uniqueness in the index, not the code Application-level checks lose every race they are given. Files: `migrations/002_unique_slot.sql`, `tests/concurrent-book.test.ts` 2. Hold first, confirm in one transaction The hold is what stops a slot vanishing while the form is being filled in. Files: `src/lib/holds.ts` 3. Make the conflict a designed outcome It will happen, and it needs copy rather than an error page. Files: `src/app/[slug]/confirm/page.tsx` ### Done when - [ ] Concurrent confirmations yield one booking - [ ] An expired hold releases the slot - [ ] The conflict path shows a human message - [ ] Cancelling frees the slot for rebooking ### Watch out - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. ## M3 · A calendar write that a timeout cannot duplicate The event is created once whatever the network does, and both parties receive a confirmation with an attachment. ### Steps 1. Derive the event identifier, do not accept one It is the only idempotency mechanism this API offers you. Files: `src/lib/event-id.ts`, `tests/event-id.test.ts` 2. Treat an already-exists response as success Because it means your first attempt worked and you did not hear. Files: `src/lib/calendar-write.ts`, `tests/retry-insert.test.ts` 3. Generate the calendar attachment locally One fewer dependency, and you control the zone it names. Files: `src/lib/ics.ts`, `tests/ics.test.ts` ### Done when - [ ] The derived identifier fits the documented alphabet and length - [ ] A retried creation produces one event - [ ] The attachment imports with the correct start and zone - [ ] Every write attempt is recorded ### Watch out - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ## M4 · Quota, expiry and the page a stranger sees (production only) The booking page behaves when the calendar refuses, the token dies or someone scrapes it, and everything exports. ### Steps 1. Cache busy intervals and show their age A public page hit by a link is the quota problem you will meet. Files: `src/lib/freebusy.ts`, `src/app/[slug]/page.tsx` 2. Back off with jitter, never in a tight loop Synchronised retries are how a small problem becomes a sustained one. Files: `src/lib/backoff.ts`, `tests/backoff.test.ts` 3. Pause the page when the token dies Offering a slot you cannot write is worse than offering nothing. Files: `src/lib/auth-state.ts`, `tests/token-revoked.test.ts` ### Done when - [ ] Rate limits trigger backoff with jitter and a cached view - [ ] A revoked token pauses bookings with an explanation - [ ] Reschedule and cancel tokens are single-use and expire - [ ] Export and reimport round-trips every table ### Watch out - A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. ===== OPERATIONS.md ===== # Operations · Calendbook ## Backup The database file nightly, kept somewhere other than the machine serving the page. Bookings are the irreplaceable part; busy intervals are a cache and can be discarded. ## Restore Restore the file, reconnect the calendar, and confirm that future bookings still carry their remote event identifiers before serving the page again. Serving with missing identifiers means a cancellation cannot reach the calendar. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calls to the calendar API per hour against the published per-user limit, the age of the cached busy intervals, the count of conflict responses, and whether the refresh token still works. A rising conflict count usually means the cache is too stale rather than that you are popular. ## Incident checklist The two real incidents are a double booking and a meeting an hour out. The first is answered by the unique index and the conflict test, the second by the daylight saving fixture, and both are cheaper to prevent than to apologise for. 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 - [ ] Slot generation is confirmed correct across both DST transitions - [ ] Concurrent confirmations are confirmed to yield one booking - [ ] A retried calendar write is confirmed to create one event - [ ] No event titles or attendees are confirmed stored - [ ] A revoked token is confirmed to pause the page rather than offer slots ## Launch constraint Do not market omitted Calendbook 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. Event types, availability, bookings. Back this up; it is the only record of who booked what. DATABASE_PATH=./data/booking.db # Required. OAuth client for the Calendar API. GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com # Required · secret. OAuth client secret. Never in the repository. GOOGLE_CLIENT_SECRET=redacted # Required · secret. Stored after the consent flow. When this stops working the page pauses. GOOGLE_REFRESH_TOKEN=1//redacted # Required. An IANA zone name, never an offset. Availability rules are wall clock in this zone. ORGANISER_TIMEZONE=Europe/London # Required. How long busy intervals are reused. Shorter means more calls against the per-user quota. BUSY_CACHE_SECONDS=120 # Required. How long a slot is held while the visitor fills in the form. HOLD_TTL_SECONDS=600 # Required · secret. Transactional provider for confirmations and cancellations. EMAIL_API_KEY=re_...redacted
# Calendbook · indie build A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. Estimated effort: **one sitting**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with a small API layer, one process, no background worker | the whole loop is request-shaped and a worker is a service you would have to keep alive | | Storage | SQLite beside the application, with a unique index doing the concurrency work | the database is the only thing that can settle a race, and SQLite can do it here | | Calendar | One Google Calendar connection, free/busy for reading and a deterministic event id for writing | reading busy intervals avoids ever handling other people's event titles | | Time | Every instant stored in UTC with the zone it was chosen in stored beside it | a local time without a zone is a bug that appears twice a year | | Email | One transactional provider, with the calendar attachment generated locally | a confirmation that arrives without an attachment is a meeting nobody records | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the application. - Get it: nodejs.org or your version manager. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud project with the Calendar API enabled and an OAuth client** · free at this volume - Why: Reading free/busy and writing an event both need it, and the consent screen configuration is the step that quietly blocks people for an afternoon. - Get it: Create the project, enable the Calendar API, create an OAuth client with your callback URL, and add yourself as a test user. - Verify: The consent flow completes and a free/busy query returns intervals - [ ] **A stable HTTPS callback URL** · free to a few pounds a month - Why: OAuth will not redirect to a URL that changes every time a tunnel restarts, and this is the most common reason the first connection never completes. - Get it: A small always-on host, or a tunnel with a reserved subdomain. - Verify: The same callback URL works across two restarts - [ ] **A transactional email provider API key** · free at this volume with most providers - Why: Confirmations carry the calendar attachment, and sending them from a home connection means they land in spam. - Get it: Any provider with a free tier; verify the sending domain. - Verify: A confirmation arrives with the attachment intact - [ ] **The organiser's timezone, written down** · free - Why: Availability rules are wall clock in that zone, and the whole daylight saving story depends on there being exactly one answer to this question. - Get it: Write the IANA zone name into configuration, not a fixed offset. - Verify: The configured value is an IANA zone name and the tests use it ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. - 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 Calendbook, and the README should say so rather than pretend.
# Build brief · Calendbook The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that offers only genuinely free slots, holds one before it is confirmed, and writes the event back exactly once to replace Calendbook. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js with a small API layer, one process, no background worker · the whole loop is request-shaped and a worker is a service you would have to keep alive - Storage: SQLite beside the application, with a unique index doing the concurrency work · the database is the only thing that can settle a race, and SQLite can do it here - Calendar: One Google Calendar connection, free/busy for reading and a deterministic event id for writing · reading busy intervals avoids ever handling other people's event titles - Time: Every instant stored in UTC with the zone it was chosen in stored beside it · a local time without a zone is a bug that appears twice a year - Email: One transactional provider, with the calendar attachment generated locally · a confirmation that arrives without an attachment is a meeting nobody records ### Data model (create this before Phase 1) - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active) - availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five' - busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored - holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form - bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at) - remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one - A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it. ### Phase 1 · Slots that survive the clocks changing Build: Read busy intervals from the calendar's free/busy query, never from the events list, so no title or attendee ever reaches your database. Generate candidate starts by stepping over UTC instants and formatting each into the organiser's zone to test it against the working-hours rules, then subtract busy intervals plus buffers, minimum notice and the booking horizon. Display in the visitor's own zone. Trap: Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. Done when: Slot generation is tested across both daylight saving transitions in a zone that observes them, and produces no duplicated or missing slot. Only the free/busy query is called and no event title, description or attendee appears anywhere in the database, verified by inspection and by a test that fails on those field names. Buffers, minimum notice and the booking horizon each remove the slots they should, tested individually. The visitor's zone is detected and shown, with an explicit control to change it, and the stored instant does not depend on it. Do not build yet: No holds, no bookings, no calendar writes, no email yet. ### Phase 2 · One slot, one person Build: Add a unique index across the event type and start instant that covers live holds and live bookings. Taking a slot inserts a hold with an expiry and a token. Confirming converts the hold to a booking in one transaction. Let the insert fail and turn the constraint violation into a friendly 'that slot just went' response that re-renders the list, rather than checking availability first and hoping. Trap: Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. Done when: Two concurrent confirmations for the same slot produce one booking and one conflict response, tested by firing both inside one test. An expired hold releases the slot and the slot becomes offerable again, tested with a moved clock. The conflict path renders a human message and a refreshed slot list, not a stack trace, checked in the end-to-end test. A cancelled booking frees its slot and the index permits a new booking at that instant, tested. Do not build yet: No calendar write, no email, no reschedule yet. ### Phase 3 · A calendar write that a timeout cannot duplicate Build: Supply your own event identifier on creation rather than letting the API allocate one. Google's reference for events.insert says the characters allowed are those used in base32hex encoding, the lowercase letters a to v and the digits 0 to 9, with a length between 5 and 1024, and that the identifier must be unique per calendar. Derive it deterministically from the booking so a retry addresses the same event. Trap: Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. Done when: The event identifier is derived from the booking identifier, contains only lowercase a to v and digits, and is between 5 and 1024 characters, asserted in a unit test including a case that would otherwise emit characters outside the alphabet. A retried creation after a simulated timeout results in one event, tested against a stub that reports the identifier already exists. The confirmation email carries a calendar attachment that imports into a calendar client with the right start, duration and zone, checked by hand once and by a parser in the suite. Every attempt is recorded in remote_writes with its status. Do not build yet: No reschedule, no cancellation, no quota handling yet. ### Phase 4 · Quota, expiry and the page a stranger sees Build: Handle rate limiting with exponential backoff plus a random component, which is what Google's quota guidance describes, and cache busy intervals for a short stated interval so a page refresh is not a call. When the refresh token stops working, put the page into a state that says bookings are paused rather than offering slots it cannot honour. Add reschedule and cancel links, and export every table as CSV. Trap: A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. Done when: A rate limit response triggers exponential backoff with a random component and the page serves cached intervals with their age shown, tested with a stub returning the limit error. A revoked refresh token puts the page into a paused state that offers no slots and tells the organiser, tested. Reschedule and cancel links are single-use tokens that expire, and a cancelled booking updates the calendar event and frees the slot, tested. Export produces event types, availability, bookings and write attempts as CSV and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a permissions model and a second calendar connection and are where this stops being a one-person tool. - Taking payment at booking, because a booking that is also a purchase brings refunds, disputes and receipts into a weekend project. - Reading full event details from the calendar, since the free/busy query answers the only question you have and keeps other people's meeting titles out of your database entirely. - Automatic reminder sequences, which are a delivery and scheduling problem of their own and need an always-on worker this build deliberately does not have. ### README must contain - That every instant is stored in UTC with the zone it was chosen in, and that both daylight saving transitions are in the test fixture - That double booking is prevented by a unique database index and not by an availability check in the code - That the calendar event identifier is derived from the booking, which is what makes a retried write safe - That only free/busy is read, so no meeting titles or attendees from the organiser's calendar are ever stored - What happens when the refresh token is revoked, and how to reconnect
# Agent instructions · Calendbook indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js with a small API layer, one process, no background worker, SQLite beside the application, with a unique index doing the concurrency work, One Google Calendar connection, free/busy for reading and a deterministic event id for writing, Every instant stored in UTC with the zone it was chosen in stored beside it, One transactional provider, with the calendar attachment generated locally. 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 repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time.
# Build plan · Calendbook A one-person booking page whose hard problems are generating slots that stay correct across a daylight saving transition, making one slot bookable by exactly one person under a double submit, and writing the calendar event in a way that a network timeout cannot turn into two meetings. 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 survive the clocks changing The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's. ### Steps 1. Read busy intervals, never events You should not be storing other people's meeting titles at all. Files: `src/lib/freebusy.ts`, `tests/no-event-detail.test.ts` 2. Generate over UTC instants, apply rules in the zone The two transitions are the only interesting test cases here. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Show the visitor's zone and let them change it The stored instant must not move when they do. Files: `src/app/[slug]/page.tsx` ### Done when - [ ] Slot generation is correct across both DST transitions - [ ] No event titles or attendees are ever stored - [ ] Buffers, notice and horizon each remove the right slots - [ ] Changing the displayed zone does not move the instant ### Watch out - Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. ## Phase 2 · One slot, one person Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the uniqueness in the index, not the code Application-level checks lose every race they are given. Files: `migrations/002_unique_slot.sql`, `tests/concurrent-book.test.ts` 2. Hold first, confirm in one transaction The hold is what stops a slot vanishing while the form is being filled in. Files: `src/lib/holds.ts` 3. Make the conflict a designed outcome It will happen, and it needs copy rather than an error page. Files: `src/app/[slug]/confirm/page.tsx` ### Done when - [ ] Concurrent confirmations yield one booking - [ ] An expired hold releases the slot - [ ] The conflict path shows a human message - [ ] Cancelling frees the slot for rebooking ### Watch out - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. ## Phase 3 · A calendar write that a timeout cannot duplicate The event is created once whatever the network does, and both parties receive a confirmation with an attachment. ### Steps 1. Derive the event identifier, do not accept one It is the only idempotency mechanism this API offers you. Files: `src/lib/event-id.ts`, `tests/event-id.test.ts` 2. Treat an already-exists response as success Because it means your first attempt worked and you did not hear. Files: `src/lib/calendar-write.ts`, `tests/retry-insert.test.ts` 3. Generate the calendar attachment locally One fewer dependency, and you control the zone it names. Files: `src/lib/ics.ts`, `tests/ics.test.ts` ### Done when - [ ] The derived identifier fits the documented alphabet and length - [ ] A retried creation produces one event - [ ] The attachment imports with the correct start and zone - [ ] Every write attempt is recorded ### Watch out - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ## Not in this build - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. ## After v1, if you want it - A second calendar connection for conflict checking only, which is a small change once free/busy is already the read path - Custom questions per event type, stored in answers_json and included in the export - A weekly digest of upcoming bookings, which is the one notification that does not need an always-on worker
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Event types, availability, bookings. Back this up; it is the only record of who booked what. DATABASE_PATH=./data/booking.db # Required. OAuth client for the Calendar API. GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com # Required · secret. OAuth client secret. Never in the repository. GOOGLE_CLIENT_SECRET=redacted # Required · secret. Stored after the consent flow. When this stops working the page pauses. GOOGLE_REFRESH_TOKEN=1//redacted # Required. An IANA zone name, never an offset. Availability rules are wall clock in this zone. ORGANISER_TIMEZONE=Europe/London # Required. How long busy intervals are reused. Shorter means more calls against the per-user quota. BUSY_CACHE_SECONDS=120 # Required. How long a slot is held while the visitor fills in the form. HOLD_TTL_SECONDS=600 # Required · secret. Transactional provider for confirmations and cancellations. EMAIL_API_KEY=re_...redacted
# Calendbook · product brief ## Problem Calendbook's solo core is compact: build a personal booking pages tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability. ## Product outcome A booking page one person can trust: slots are correct on the weekend the clocks change, one slot can only be taken once because the database says so, a timed-out calendar write does not create a second meeting, and the page pauses honestly when the connection breaks. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team routing and round robin. - Payment at booking. - Reading full event details from the calendar. - Automated reminder sequences. - 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 - Slot generation is confirmed correct across both DST transitions - Concurrent confirmations are confirmed to yield one booking - A retried calendar write is confirmed to create one event - No event titles or attendees are confirmed stored - A revoked token is confirmed to pause the page rather than offer slots
# Build brief · Calendbook The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that offers only genuinely free slots, holds one before it is confirmed, and writes the event back exactly once to replace Calendbook. 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. ### Stack (fixed, do not substitute) - Runtime: Next.js with a small API layer, one process, no background worker · the whole loop is request-shaped and a worker is a service you would have to keep alive - Storage: SQLite beside the application, with a unique index doing the concurrency work · the database is the only thing that can settle a race, and SQLite can do it here - Calendar: One Google Calendar connection, free/busy for reading and a deterministic event id for writing · reading busy intervals avoids ever handling other people's event titles - Time: Every instant stored in UTC with the zone it was chosen in stored beside it · a local time without a zone is a bug that appears twice a year - Email: One transactional provider, with the calendar attachment generated locally · a confirmation that arrives without an attachment is a meeting nobody records ### Data model (create this before Phase 1) - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, organiser_timezone, active) - availability_rules(id, event_type_id, weekday, start_local, end_local) · wall clock in the organiser's zone, which is the only sane way to write 'I work nine to five' - busy_intervals(id, fetched_at, start_utc, end_utc) · opaque intervals from the free/busy query, with no titles, attendees or descriptions ever stored - holds(id, event_type_id, start_utc, token, expires_at) · a short-lived claim on a slot while the visitor fills in the form - bookings(id, event_type_id, start_utc, end_utc, invitee_name, invitee_email, invitee_timezone, answers_json, state, remote_event_id, created_at, cancelled_at) - remote_writes(id, booking_id, deterministic_event_id, attempt, http_status, at) · the identifier is derived from the booking, so a retry targets the same event rather than making a new one - A unique index over (event_type_id, start_utc) covering live holds and live bookings is what actually prevents a double booking. Nothing in the application layer is trusted to do it. ### Phase 1 · Slots that survive the clocks changing Build: Read busy intervals from the calendar's free/busy query, never from the events list, so no title or attendee ever reaches your database. Generate candidate starts by stepping over UTC instants and formatting each into the organiser's zone to test it against the working-hours rules, then subtract busy intervals plus buffers, minimum notice and the booking horizon. Display in the visitor's own zone. Trap: Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. Done when: Slot generation is tested across both daylight saving transitions in a zone that observes them, and produces no duplicated or missing slot. Only the free/busy query is called and no event title, description or attendee appears anywhere in the database, verified by inspection and by a test that fails on those field names. Buffers, minimum notice and the booking horizon each remove the slots they should, tested individually. The visitor's zone is detected and shown, with an explicit control to change it, and the stored instant does not depend on it. Do not build yet: No holds, no bookings, no calendar writes, no email yet. ### Phase 2 · One slot, one person Build: Add a unique index across the event type and start instant that covers live holds and live bookings. Taking a slot inserts a hold with an expiry and a token. Confirming converts the hold to a booking in one transaction. Let the insert fail and turn the constraint violation into a friendly 'that slot just went' response that re-renders the list, rather than checking availability first and hoping. Trap: Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. Done when: Two concurrent confirmations for the same slot produce one booking and one conflict response, tested by firing both inside one test. An expired hold releases the slot and the slot becomes offerable again, tested with a moved clock. The conflict path renders a human message and a refreshed slot list, not a stack trace, checked in the end-to-end test. A cancelled booking frees its slot and the index permits a new booking at that instant, tested. Do not build yet: No calendar write, no email, no reschedule yet. ### Phase 3 · A calendar write that a timeout cannot duplicate Build: Supply your own event identifier on creation rather than letting the API allocate one. Google's reference for events.insert says the characters allowed are those used in base32hex encoding, the lowercase letters a to v and the digits 0 to 9, with a length between 5 and 1024, and that the identifier must be unique per calendar. Derive it deterministically from the booking so a retry addresses the same event. Trap: Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. Done when: The event identifier is derived from the booking identifier, contains only lowercase a to v and digits, and is between 5 and 1024 characters, asserted in a unit test including a case that would otherwise emit characters outside the alphabet. A retried creation after a simulated timeout results in one event, tested against a stub that reports the identifier already exists. The confirmation email carries a calendar attachment that imports into a calendar client with the right start, duration and zone, checked by hand once and by a parser in the suite. Every attempt is recorded in remote_writes with its status. Do not build yet: No reschedule, no cancellation, no quota handling yet. ### Phase 4 · Quota, expiry and the page a stranger sees Build: Handle rate limiting with exponential backoff plus a random component, which is what Google's quota guidance describes, and cache busy intervals for a short stated interval so a page refresh is not a call. When the refresh token stops working, put the page into a state that says bookings are paused rather than offering slots it cannot honour. Add reschedule and cancel links, and export every table as CSV. Trap: A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error. Done when: A rate limit response triggers exponential backoff with a random component and the page serves cached intervals with their age shown, tested with a stub returning the limit error. A revoked refresh token puts the page into a paused state that offers no slots and tells the organiser, tested. Reschedule and cancel links are single-use tokens that expire, and a cancelled booking updates the calendar event and frees the slot, tested. Export produces event types, availability, bookings and write attempts as CSV and reimports into an empty database, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and shared availability, which need a permissions model and a second calendar connection and are where this stops being a one-person tool. - Taking payment at booking, because a booking that is also a purchase brings refunds, disputes and receipts into a weekend project. - Reading full event details from the calendar, since the free/busy query answers the only question you have and keeps other people's meeting titles out of your database entirely. - Automatic reminder sequences, which are a delivery and scheduling problem of their own and need an always-on worker this build deliberately does not have. ### README must contain - That every instant is stored in UTC with the zone it was chosen in, and that both daylight saving transitions are in the test fixture - That double booking is prevented by a unique database index and not by an availability check in the code - That the calendar event identifier is derived from the booking, which is what makes a retried write safe - That only free/busy is read, so no meeting titles or attendees from the organiser's calendar are ever stored - What happens when the refresh token is revoked, and how to reconnect
# Architecture · Calendbook ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js with a small API layer, one process, no background worker | the whole loop is request-shaped and a worker is a service you would have to keep alive | | Storage | SQLite beside the application, with a unique index doing the concurrency work | the database is the only thing that can settle a race, and SQLite can do it here | | Calendar | One Google Calendar connection, free/busy for reading and a deterministic event id for writing | reading busy intervals avoids ever handling other people's event titles | | Time | Every instant stored in UTC with the zone it was chosen in stored beside it | a local time without a zone is a bug that appears twice a year | | Email | One transactional provider, with the calendar attachment generated locally | a confirmation that arrives without an attachment is a meeting nobody records | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Slots | Rules, buffers, horizon and instant generation. | Any implementation, provided it iterates instants rather than wall-clock times. | | Holds | Short-lived claims and their expiry. | Any store, provided the unique index covers holds and bookings together. | | Calendar | Free/busy reads and deterministic event writes. | Any provider that lets the client choose an event identifier; without that, the retry story changes. | | Notify | Confirmations, cancellations and the attachment. | Any provider, provided the attachment is generated locally. | | Export | CSV of every table and reimport. | Any format, provided bookings survive the application being deleted. | ## 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 · Event types, availability, bookings. Back this up; it is the only record of who booked what. - `GOOGLE_CLIENT_ID` · required · OAuth client for the Calendar API. - `GOOGLE_CLIENT_SECRET` · required, secret · OAuth client secret. Never in the repository. - `GOOGLE_REFRESH_TOKEN` · required, secret · Stored after the consent flow. When this stops working the page pauses. - `ORGANISER_TIMEZONE` · required · An IANA zone name, never an offset. Availability rules are wall clock in this zone. - `BUSY_CACHE_SECONDS` · required · How long busy intervals are reused. Shorter means more calls against the per-user quota. - `HOLD_TTL_SECONDS` · required · How long a slot is held while the visitor fills in the form. - `EMAIL_API_KEY` · required, secret · Transactional provider for confirmations and cancellations. ## 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 · Calendbook product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js with a small API layer, one process, no background worker, SQLite beside the application, with a unique index doing the concurrency work, One Google Calendar connection, free/busy for reading and a deterministic event id for writing, Every instant stored in UTC with the zone it was chosen in stored beside it, One transactional provider, with the calendar attachment generated locally. - 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 repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. - A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error.
# Delivery milestones · Calendbook Estimated effort: **one sitting** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Slots that survive the clocks changing The offered slots are generated from real busy intervals and stay correct across a daylight saving transition in either the organiser's zone or the visitor's. ### Steps 1. Read busy intervals, never events You should not be storing other people's meeting titles at all. Files: `src/lib/freebusy.ts`, `tests/no-event-detail.test.ts` 2. Generate over UTC instants, apply rules in the zone The two transitions are the only interesting test cases here. Files: `src/lib/slots.ts`, `tests/dst.test.ts` 3. Show the visitor's zone and let them change it The stored instant must not move when they do. Files: `src/app/[slug]/page.tsx` ### Done when - [ ] Slot generation is correct across both DST transitions - [ ] No event titles or attendees are ever stored - [ ] Buffers, notice and horizon each remove the right slots - [ ] Changing the displayed zone does not move the instant ### Watch out - Generating slots by repeatedly adding the duration to a local wall-clock time gives you an hour that does not exist on the spring transition and an hour that happens twice on the autumn one, and the booking that lands in it will be an hour wrong for one of the two people. Step over UTC instants, convert to the zone only to apply the rules, and put both transition dates in the test fixture. ## M2 · One slot, one person Two people submitting the same slot at the same moment produce one booking and one clear refusal. ### Steps 1. Put the uniqueness in the index, not the code Application-level checks lose every race they are given. Files: `migrations/002_unique_slot.sql`, `tests/concurrent-book.test.ts` 2. Hold first, confirm in one transaction The hold is what stops a slot vanishing while the form is being filled in. Files: `src/lib/holds.ts` 3. Make the conflict a designed outcome It will happen, and it needs copy rather than an error page. Files: `src/app/[slug]/confirm/page.tsx` ### Done when - [ ] Concurrent confirmations yield one booking - [ ] An expired hold releases the slot - [ ] The conflict path shows a human message - [ ] Cancelling frees the slot for rebooking ### Watch out - Checking whether the slot is free and then inserting the booking is two statements with a gap in the middle, and the gap is exactly wide enough for the second person who clicked at the same time, which is how two clients arrive at the same video call. Let the unique index be the arbiter, catch the violation, and treat the conflict as an expected outcome with real interface copy rather than as a 500. ## M3 · A calendar write that a timeout cannot duplicate The event is created once whatever the network does, and both parties receive a confirmation with an attachment. ### Steps 1. Derive the event identifier, do not accept one It is the only idempotency mechanism this API offers you. Files: `src/lib/event-id.ts`, `tests/event-id.test.ts` 2. Treat an already-exists response as success Because it means your first attempt worked and you did not hear. Files: `src/lib/calendar-write.ts`, `tests/retry-insert.test.ts` 3. Generate the calendar attachment locally One fewer dependency, and you control the zone it names. Files: `src/lib/ics.ts`, `tests/ics.test.ts` ### Done when - [ ] The derived identifier fits the documented alphabet and length - [ ] A retried creation produces one event - [ ] The attachment imports with the correct start and zone - [ ] Every write attempt is recorded ### Watch out - Letting the API allocate the event identifier means a request that times out after the event was created leaves you with no identifier and no idea, so your retry creates a second meeting in the organiser's calendar and neither one can be cancelled by the booking page. Derive the identifier from the booking, encode it into the permitted base32hex alphabet, and treat a duplicate response as success rather than as an error, noting that Google states identifier collisions are not guaranteed to be detected at creation time. ## M4 · Quota, expiry and the page a stranger sees (production only) The booking page behaves when the calendar refuses, the token dies or someone scrapes it, and everything exports. ### Steps 1. Cache busy intervals and show their age A public page hit by a link is the quota problem you will meet. Files: `src/lib/freebusy.ts`, `src/app/[slug]/page.tsx` 2. Back off with jitter, never in a tight loop Synchronised retries are how a small problem becomes a sustained one. Files: `src/lib/backoff.ts`, `tests/backoff.test.ts` 3. Pause the page when the token dies Offering a slot you cannot write is worse than offering nothing. Files: `src/lib/auth-state.ts`, `tests/token-revoked.test.ts` ### Done when - [ ] Rate limits trigger backoff with jitter and a cached view - [ ] A revoked token pauses bookings with an explanation - [ ] Reschedule and cancel tokens are single-use and expire - [ ] Export and reimport round-trips every table ### Watch out - A public booking page that queries free/busy on every load will burn the per-user quota the first time it is linked anywhere busy, and Google publishes both a per-project and a much smaller per-user-per-project per-minute limit, so the page starts returning errors to real visitors. Cache the busy intervals for a stated interval, back off with jitter on a rate limit, and serve the cached view with its age shown rather than an error.
# Operations · Calendbook ## Backup The database file nightly, kept somewhere other than the machine serving the page. Bookings are the irreplaceable part; busy intervals are a cache and can be discarded. ## Restore Restore the file, reconnect the calendar, and confirm that future bookings still carry their remote event identifiers before serving the page again. Serving with missing identifiers means a cancellation cannot reach the calendar. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calls to the calendar API per hour against the published per-user limit, the age of the cached busy intervals, the count of conflict responses, and whether the refresh token still works. A rising conflict count usually means the cache is too stale rather than that you are popular. ## Incident checklist The two real incidents are a double booking and a meeting an hour out. The first is answered by the unique index and the conflict test, the second by the daylight saving fixture, and both are cheaper to prevent than to apologise for. 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 - [ ] Slot generation is confirmed correct across both DST transitions - [ ] Concurrent confirmations are confirmed to yield one booking - [ ] A retried calendar write is confirmed to create one event - [ ] No event titles or attendees are confirmed stored - [ ] A revoked token is confirmed to pause the page rather than offer slots ## Launch constraint Do not market omitted Calendbook 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. Event types, availability, bookings. Back this up; it is the only record of who booked what. DATABASE_PATH=./data/booking.db # Required. OAuth client for the Calendar API. GOOGLE_CLIENT_ID=1234-abc.apps.googleusercontent.com # Required · secret. OAuth client secret. Never in the repository. GOOGLE_CLIENT_SECRET=redacted # Required · secret. Stored after the consent flow. When this stops working the page pauses. GOOGLE_REFRESH_TOKEN=1//redacted # Required. An IANA zone name, never an offset. Availability rules are wall clock in this zone. ORGANISER_TIMEZONE=Europe/London # Required. How long busy intervals are reused. Shorter means more calls against the per-user quota. BUSY_CACHE_SECONDS=120 # Required. How long a slot is held while the visitor fills in the form. HOLD_TTL_SECONDS=600 # Required · secret. Transactional provider for confirmations and cancellations. EMAIL_API_KEY=re_...redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
Calendbook: 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.
all 3 free alternatives to Calendbook →· no votes, no pay-to-list · just what's real
Calendbook pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Unlimited bookings, 1 event type, 1 connected calendar, username/booking page and embed |
| premium | — | $2/user | Unlimited event types, up to 10 calendar connections, Zapier and webhooks |
| professional | — | $5/user | Premium features plus custom branding, advanced availability and accept/reject controls |
free tierUnlimited bookings, 1 event type and 1 connected calendar, according to the current homepage
billingannual billing on the current official pricing cards; no monthly rate disclosed
hidden costsThe official homepage and integrations page conflict over whether Free has unlimited bookings, so that limit should be treated cautiously. Paid plans are per user and annual-only on the current public card.
verified 2026-08-14 · source ↗
Vibecode Calendbook
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Calendbook replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Calendbook cost?
Calendbook's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Calendbook?
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 Calendbook?
Yes: Appointy Free (One staffer gets reminders, classes and Square checkout for 100 bookings a month; growth wakes the paywall.) Cal.com Free (Unlimited solo event types, calendars and bookings, with payments and notifications; team routing costs money.) zcal Free (Unlimited links and calendars, custom questions, email reminders and Stripe payments; teams are paid and zcal takes 3%.) All 3 curated free alternatives are at vibecodeit.com/calendbook/alternatives. The prompt is for when you want it exactly your way.