Vibecode Calday
track this build4 phases, 12 steps, beginner friendly0%Calday's solo core is compact: build a personal booking links 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 Calday. 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 ===== # Calday · indie build A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js on a host with a stable public URL for the OAuth callback and the booking page | the person booking has to be able to reach it, which rules out running it on your laptop | | Store | SQLite with a unique index on (link_id, slot_start_utc) | the database is the lock and the calendar is the record, in that order | | Availability | The provider's free/busy query, batched, never a listing of events | it returns intervals only, which is both the correct answer and the private one | | Time | Instants in UTC plus the IANA zone name stored beside every rule | an offset is not a timezone and stops being right in March | | Notification | One transactional email per booking, sent after the calendar write succeeds | an email about a booking that did not land is worse than no email | ## 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 a version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with a free/busy scope** · free - Why: Free/busy is a narrower permission than reading events, and asking for the narrower one is the difference between a booking page that cannot leak your day and one that can. - Get it: Create a project in the provider's console, configure the consent screen and register the callback URL. - Verify: A free/busy call returns intervals and a call for event details is refused by the scope - [ ] **A public HTTPS URL for the callback and the booking page** · a few pounds a month, or free on a small tier - Why: The OAuth callback and the person booking both need to reach it, so a laptop behind a router will not do once this is real. - Get it: A small host or a tunnel with a stable hostname. - Verify: The callback completes and the booking page loads from another network - [ ] **A transactional email provider** · free at personal volume - Why: Confirmations and cancellations are the part people notice, and sending them from a home address means they land in spam. - Get it: Any provider with an API and a verified sending domain. - Verify: A test confirmation with a calendar attachment arrives and the attachment opens - [ ] **Your zone, and a second one to test against** · free - Why: Daylight saving bugs only appear on transition weeks and only in zones that have them, so pick the two zones your tests will cover before writing the expansion code. - Get it: Choose your own zone plus one in the opposite hemisphere. - Verify: Both appear in the test suite with a spring and an autumn case ## 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, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. - 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 Calday, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Calday The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that publishes real availability from one calendar and writes the booking back without ever double-booking you to replace Calday. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two things, and both are the reason anyone pays for this category at all. It must never double-book you, and the calendar API is not the lock. Two people pressing confirm in the same second will both get an event, because a calendar will happily accept two overlapping entries and tell you nothing. The lock is a unique index in your own database on the link and the slot start. You insert there first; the second insert fails and that person is told the slot went, before anything reaches the calendar at all. And it must never leak your private events. Compute availability from the provider's free/busy query, which returns busy intervals and nothing else: no titles, no guests, no descriptions, no locations. Listing events to work out when you are busy means your booking page is now holding the subject lines of your day, and one templating mistake puts them on a public URL. It also gets the answer wrong, because the calendar's own transparency field decides whether an event blocks time, and free/busy honours that for you while an event list does not. The corollary: when the availability call fails, show no slots and say why. Never treat an error as an empty busy list, because that renders your entire week as free. ### Stack (fixed, do not substitute) - Runtime: Next.js on a host with a stable public URL for the OAuth callback and the booking page · the person booking has to be able to reach it, which rules out running it on your laptop - Store: SQLite with a unique index on (link_id, slot_start_utc) · the database is the lock and the calendar is the record, in that order - Availability: The provider's free/busy query, batched, never a listing of events · it returns intervals only, which is both the correct answer and the private one - Time: Instants in UTC plus the IANA zone name stored beside every rule · an offset is not a timezone and stops being right in March - Notification: One transactional email per booking, sent after the calendar write succeeds · an email about a booking that did not land is worse than no email ### Data model (create this before Phase 1) - links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier. ### Phase 1 · Availability from rules and free/busy Build: Expand the weekly rules and overrides into candidate intervals for a window, subtract the busy intervals returned by the provider's free/busy query, apply buffers, minimum notice and the maximum days ahead, and serve the remainder as slot start times in the invitee's zone. Trap: Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. Done when: The availability response contains start and end times and nothing else, asserted by a test that fails if any event title, guest or description appears in the JSON. An event marked transparent on the source calendar does not remove a slot, tested against a real calendar. Buffers, minimum notice and maximum days ahead each change the slot list in a test that sets them to zero and then to a value. Calendars are batched under the provider's documented expansion limit rather than sent in one request. Do not build yet: No booking, no holds, no email and no calendar writes yet. ### Phase 2 · A slot that can only be taken once Build: Take a hold with an expiry when the form opens, and show the countdown. On submit, insert the booking under the unique index inside a transaction, and only then write the calendar event. A calendar write that fails leaves the booking in a needs-attention state, not deleted. Trap: Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. Done when: Two concurrent submissions for the same slot result in exactly one booking row and one calendar event, tested with parallel requests. The losing request gets a specific message and alternative slots, not a generic failure. A hold expires and the slot becomes bookable again, tested by moving the clock. A calendar write failure leaves the booking row present and flagged, and the invitee is told the booking is confirmed but not yet on the calendar. Do not build yet: No timezone rendering for the invitee, no emails and no cancellation yet. ### Phase 3 · Timezones that survive the clock change Build: Render the picker in the invitee's detected zone with an explicit selector. Expand each rule day by day through the zone database rather than by applying one computed offset to the week. Handle local times that do not exist and local times that happen twice. Trap: Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. Done when: A rule of nine to five in a zone with daylight saving produces correct UTC instants on both sides of a transition, tested for both the spring and autumn weeks. A local time that does not exist on a transition day is skipped rather than shifted silently, tested. A local time that occurs twice resolves to a documented choice and that choice is stated in the code. The invitee's zone is stored with the booking and used in every message sent to them. Do not build yet: No emails, calendar invitations, cancellations or reschedules yet. ### Phase 4 · Confirmations, cancellations and reschedules Build: Send one confirmation with a calendar attachment carrying a stable unique identifier. Provide cancel and reschedule links carrying a per-booking token. A reschedule releases the old slot and takes the new one in one transaction, and updates rather than replaces the calendar entry. Trap: Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. Done when: A reschedule produces one entry in the invitee's calendar, not two, verified in at least two mail clients. The calendar attachment keeps the same unique identifier across confirm, reschedule and cancel, with an increasing sequence, asserted in the suite. A cancel link is single use and invalid tokens fail without revealing whether the booking exists. Email sending failures are retried and surfaced, and never cause the booking itself to be lost. Do not build yet: No token refresh, retention or export yet. ### Phase 5 · Tokens, failure and the invitee's data Build: Refresh the calendar token ahead of expiry and warn when refresh fails. When availability cannot be fetched, show no slots with a plain explanation. Give invitee records a retention period, provide a delete-by-email function, and export everything as CSV plus calendar files. Trap: Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. Done when: A forced availability failure results in zero slots and an explanatory message, tested by making the provider call throw. A cache older than its TTL is marked stale and is never served as current availability. Invitee records past the retention period are deleted and the bookings survive as anonymised rows, tested. A delete-by-email request removes that person from bookings, holds and email logs, verified by searching the database for the address afterwards. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which needs several people's calendars and a fairness rule, and is the feature the paid tiers exist for. - Taking payment at booking, which brings refunds, disputes and tax handling into what is otherwise a weekend build. - Text message reminders, which need a provider, a sender registration and a per-message cost for something email already does. - Writing to more than one calendar provider, because each has its own recurrence and free/busy quirks and two is more than twice the work of one. ### README must contain - That the unique index on link and slot start is the double-booking defence, and the calendar is not - That availability comes from free/busy so the page never sees your event titles, and why an event listing would be both less private and less correct - That a failed availability fetch shows no slots on purpose, and how to tell that from having no availability - That rules are stored as local wall time plus an IANA zone name, and which transition weeks are covered by tests - Where invitee personal data lives, how long it is kept, and how to delete one person entirely ===== AGENTS.md ===== # Agent instructions · Calday indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js on a host with a stable public URL for the OAuth callback and the booking page, SQLite with a unique index on (link_id, slot_start_utc), The provider's free/busy query, batched, never a listing of events, Instants in UTC plus the IANA zone name stored beside every rule, One transactional email per booking, sent after the calendar write succeeds. 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 - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ===== BUILD_PLAN.md ===== # Build plan · Calday A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability from rules and free/busy A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response. ### Steps 1. Use free/busy, never an event listing It is the private answer and it is also the correct one. Files: `src/lib/availability.ts`, `tests/no-event-details.test.ts` 2. Expand rules to intervals, then subtract busy Two clear steps beat one clever query. Files: `src/lib/rules.ts`, `tests/rules.test.ts` 3. Batch calendars under the documented expansion cap Check the current cap; do not copy this number from a blog post. Files: `src/lib/freebusy.ts` ### Done when - [ ] No event title or guest can appear in the response - [ ] A transparent event does not block a slot - [ ] Buffers, notice and horizon each change the slot list - [ ] Calendars are batched under the documented cap ### Watch out - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. ## Phase 2 · A slot that can only be taken once Two simultaneous bookings for the same slot end with one confirmation and one clear refusal. ### Steps 1. Insert under the unique index before touching the calendar The database is the only lock in this system. Files: `src/lib/book.ts`, `src/db/schema.sql` 2. Hold the slot when the form opens, with an expiry It turns a race into a rare case rather than a common one. Files: `src/lib/holds.ts`, `tests/holds.test.ts` 3. Make a failed calendar write a state, not a rollback The person booked; losing their booking is the worse outcome. Files: `src/lib/book.ts` ### Done when - [ ] Concurrent submissions produce one booking - [ ] The loser is told, with alternatives - [ ] An expired hold frees the slot - [ ] A failed calendar write flags rather than deletes ### Watch out - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. ## Phase 3 · Timezones that survive the clock change A weekly rule renders correctly for both parties through a daylight saving transition. ### Steps 1. Expand rules per day through the zone database One offset for the week is wrong for half of it. Files: `src/lib/expand.ts`, `tests/dst.test.ts` 2. Decide what a non-existent local time does Skip it, and write down that you skipped it. Files: `src/lib/expand.ts` 3. Store the invitee zone, do not infer it again later Their browser may be somewhere else by the time you email them. Files: `src/db/schema.sql` ### Done when - [ ] Both transition weeks produce correct instants - [ ] A non-existent local time is skipped - [ ] An ambiguous local time has a documented resolution - [ ] The invitee zone is stored and reused ### Watch out - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. ## Phase 4 · Confirmations, cancellations and reschedules Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost. ### Steps 1. Keep one identifier and raise the sequence Check the current requirement in the calendaring spec your clients read. Files: `src/lib/ics.ts`, `tests/ics-sequence.test.ts` 2. Make reschedule one transaction across both slots Releasing then taking can leave both slots gone. Files: `src/lib/reschedule.ts` 3. Test against two real mail clients They disagree about almost everything in this format. Files: `docs/ics-testing.md` ### Done when - [ ] A reschedule leaves one calendar entry - [ ] The identifier is stable and the sequence rises - [ ] Cancel tokens are single use and leak nothing - [ ] A failed email never loses the booking ### Watch out - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ## Not in this build - Team routing, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. ## After v1, if you want it - A second link type with different duration and buffers, which mostly tests whether your rules really are per link - An availability preview for yourself that shows why a slot is missing, which is the debugging tool you will want first - Recurring bookings, which are worth doing only after the single-booking path has survived a real clock change ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Links, rules, holds and bookings. Back this up; it is the lock. DATABASE_PATH=./data/booking.db # Required. The registered application. Public half of the OAuth pair. CALENDAR_CLIENT_ID=123456789-abc.apps.example.com # Required · secret. Never in the repository, never in a client bundle. CALENDAR_CLIENT_SECRET=secret-...redacted # Required. Used for the callback and in every link you email. Must match the registered callback. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Transactional email. Confirmations, cancellations and reschedules. EMAIL_API_KEY=re_...redacted # Required. How long cached busy intervals are fresh. Past this they are stale, not current. FREEBUSY_TTL_SECONDS=120 # Required. How long the form holds a slot. Long enough to type, short enough not to block. HOLD_MINUTES=10 # Required. After this the name, email and note are removed and the booking stays. INVITEE_RETENTION_DAYS=365
You are building a lean indie version of Calday. 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 ===== # Calday · indie build A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js on a host with a stable public URL for the OAuth callback and the booking page | the person booking has to be able to reach it, which rules out running it on your laptop | | Store | SQLite with a unique index on (link_id, slot_start_utc) | the database is the lock and the calendar is the record, in that order | | Availability | The provider's free/busy query, batched, never a listing of events | it returns intervals only, which is both the correct answer and the private one | | Time | Instants in UTC plus the IANA zone name stored beside every rule | an offset is not a timezone and stops being right in March | | Notification | One transactional email per booking, sent after the calendar write succeeds | an email about a booking that did not land is worse than no email | ## 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 a version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with a free/busy scope** · free - Why: Free/busy is a narrower permission than reading events, and asking for the narrower one is the difference between a booking page that cannot leak your day and one that can. - Get it: Create a project in the provider's console, configure the consent screen and register the callback URL. - Verify: A free/busy call returns intervals and a call for event details is refused by the scope - [ ] **A public HTTPS URL for the callback and the booking page** · a few pounds a month, or free on a small tier - Why: The OAuth callback and the person booking both need to reach it, so a laptop behind a router will not do once this is real. - Get it: A small host or a tunnel with a stable hostname. - Verify: The callback completes and the booking page loads from another network - [ ] **A transactional email provider** · free at personal volume - Why: Confirmations and cancellations are the part people notice, and sending them from a home address means they land in spam. - Get it: Any provider with an API and a verified sending domain. - Verify: A test confirmation with a calendar attachment arrives and the attachment opens - [ ] **Your zone, and a second one to test against** · free - Why: Daylight saving bugs only appear on transition weeks and only in zones that have them, so pick the two zones your tests will cover before writing the expansion code. - Get it: Choose your own zone plus one in the opposite hemisphere. - Verify: Both appear in the test suite with a spring and an autumn case ## 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, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. - 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 Calday, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Calday The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that publishes real availability from one calendar and writes the booking back without ever double-booking you to replace Calday. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two things, and both are the reason anyone pays for this category at all. It must never double-book you, and the calendar API is not the lock. Two people pressing confirm in the same second will both get an event, because a calendar will happily accept two overlapping entries and tell you nothing. The lock is a unique index in your own database on the link and the slot start. You insert there first; the second insert fails and that person is told the slot went, before anything reaches the calendar at all. And it must never leak your private events. Compute availability from the provider's free/busy query, which returns busy intervals and nothing else: no titles, no guests, no descriptions, no locations. Listing events to work out when you are busy means your booking page is now holding the subject lines of your day, and one templating mistake puts them on a public URL. It also gets the answer wrong, because the calendar's own transparency field decides whether an event blocks time, and free/busy honours that for you while an event list does not. The corollary: when the availability call fails, show no slots and say why. Never treat an error as an empty busy list, because that renders your entire week as free. ### Stack (fixed, do not substitute) - Runtime: Next.js on a host with a stable public URL for the OAuth callback and the booking page · the person booking has to be able to reach it, which rules out running it on your laptop - Store: SQLite with a unique index on (link_id, slot_start_utc) · the database is the lock and the calendar is the record, in that order - Availability: The provider's free/busy query, batched, never a listing of events · it returns intervals only, which is both the correct answer and the private one - Time: Instants in UTC plus the IANA zone name stored beside every rule · an offset is not a timezone and stops being right in March - Notification: One transactional email per booking, sent after the calendar write succeeds · an email about a booking that did not land is worse than no email ### Data model (create this before Phase 1) - links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier. ### Phase 1 · Availability from rules and free/busy Build: Expand the weekly rules and overrides into candidate intervals for a window, subtract the busy intervals returned by the provider's free/busy query, apply buffers, minimum notice and the maximum days ahead, and serve the remainder as slot start times in the invitee's zone. Trap: Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. Done when: The availability response contains start and end times and nothing else, asserted by a test that fails if any event title, guest or description appears in the JSON. An event marked transparent on the source calendar does not remove a slot, tested against a real calendar. Buffers, minimum notice and maximum days ahead each change the slot list in a test that sets them to zero and then to a value. Calendars are batched under the provider's documented expansion limit rather than sent in one request. Do not build yet: No booking, no holds, no email and no calendar writes yet. ### Phase 2 · A slot that can only be taken once Build: Take a hold with an expiry when the form opens, and show the countdown. On submit, insert the booking under the unique index inside a transaction, and only then write the calendar event. A calendar write that fails leaves the booking in a needs-attention state, not deleted. Trap: Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. Done when: Two concurrent submissions for the same slot result in exactly one booking row and one calendar event, tested with parallel requests. The losing request gets a specific message and alternative slots, not a generic failure. A hold expires and the slot becomes bookable again, tested by moving the clock. A calendar write failure leaves the booking row present and flagged, and the invitee is told the booking is confirmed but not yet on the calendar. Do not build yet: No timezone rendering for the invitee, no emails and no cancellation yet. ### Phase 3 · Timezones that survive the clock change Build: Render the picker in the invitee's detected zone with an explicit selector. Expand each rule day by day through the zone database rather than by applying one computed offset to the week. Handle local times that do not exist and local times that happen twice. Trap: Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. Done when: A rule of nine to five in a zone with daylight saving produces correct UTC instants on both sides of a transition, tested for both the spring and autumn weeks. A local time that does not exist on a transition day is skipped rather than shifted silently, tested. A local time that occurs twice resolves to a documented choice and that choice is stated in the code. The invitee's zone is stored with the booking and used in every message sent to them. Do not build yet: No emails, calendar invitations, cancellations or reschedules yet. ### Phase 4 · Confirmations, cancellations and reschedules Build: Send one confirmation with a calendar attachment carrying a stable unique identifier. Provide cancel and reschedule links carrying a per-booking token. A reschedule releases the old slot and takes the new one in one transaction, and updates rather than replaces the calendar entry. Trap: Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. Done when: A reschedule produces one entry in the invitee's calendar, not two, verified in at least two mail clients. The calendar attachment keeps the same unique identifier across confirm, reschedule and cancel, with an increasing sequence, asserted in the suite. A cancel link is single use and invalid tokens fail without revealing whether the booking exists. Email sending failures are retried and surfaced, and never cause the booking itself to be lost. Do not build yet: No token refresh, retention or export yet. ### Phase 5 · Tokens, failure and the invitee's data Build: Refresh the calendar token ahead of expiry and warn when refresh fails. When availability cannot be fetched, show no slots with a plain explanation. Give invitee records a retention period, provide a delete-by-email function, and export everything as CSV plus calendar files. Trap: Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. Done when: A forced availability failure results in zero slots and an explanatory message, tested by making the provider call throw. A cache older than its TTL is marked stale and is never served as current availability. Invitee records past the retention period are deleted and the bookings survive as anonymised rows, tested. A delete-by-email request removes that person from bookings, holds and email logs, verified by searching the database for the address afterwards. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which needs several people's calendars and a fairness rule, and is the feature the paid tiers exist for. - Taking payment at booking, which brings refunds, disputes and tax handling into what is otherwise a weekend build. - Text message reminders, which need a provider, a sender registration and a per-message cost for something email already does. - Writing to more than one calendar provider, because each has its own recurrence and free/busy quirks and two is more than twice the work of one. ### README must contain - That the unique index on link and slot start is the double-booking defence, and the calendar is not - That availability comes from free/busy so the page never sees your event titles, and why an event listing would be both less private and less correct - That a failed availability fetch shows no slots on purpose, and how to tell that from having no availability - That rules are stored as local wall time plus an IANA zone name, and which transition weeks are covered by tests - Where invitee personal data lives, how long it is kept, and how to delete one person entirely ===== AGENTS.md ===== # Agent instructions · Calday indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js on a host with a stable public URL for the OAuth callback and the booking page, SQLite with a unique index on (link_id, slot_start_utc), The provider's free/busy query, batched, never a listing of events, Instants in UTC plus the IANA zone name stored beside every rule, One transactional email per booking, sent after the calendar write succeeds. 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 - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ===== BUILD_PLAN.md ===== # Build plan · Calday A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability from rules and free/busy A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response. ### Steps 1. Use free/busy, never an event listing It is the private answer and it is also the correct one. Files: `src/lib/availability.ts`, `tests/no-event-details.test.ts` 2. Expand rules to intervals, then subtract busy Two clear steps beat one clever query. Files: `src/lib/rules.ts`, `tests/rules.test.ts` 3. Batch calendars under the documented expansion cap Check the current cap; do not copy this number from a blog post. Files: `src/lib/freebusy.ts` ### Done when - [ ] No event title or guest can appear in the response - [ ] A transparent event does not block a slot - [ ] Buffers, notice and horizon each change the slot list - [ ] Calendars are batched under the documented cap ### Watch out - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. ## Phase 2 · A slot that can only be taken once Two simultaneous bookings for the same slot end with one confirmation and one clear refusal. ### Steps 1. Insert under the unique index before touching the calendar The database is the only lock in this system. Files: `src/lib/book.ts`, `src/db/schema.sql` 2. Hold the slot when the form opens, with an expiry It turns a race into a rare case rather than a common one. Files: `src/lib/holds.ts`, `tests/holds.test.ts` 3. Make a failed calendar write a state, not a rollback The person booked; losing their booking is the worse outcome. Files: `src/lib/book.ts` ### Done when - [ ] Concurrent submissions produce one booking - [ ] The loser is told, with alternatives - [ ] An expired hold frees the slot - [ ] A failed calendar write flags rather than deletes ### Watch out - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. ## Phase 3 · Timezones that survive the clock change A weekly rule renders correctly for both parties through a daylight saving transition. ### Steps 1. Expand rules per day through the zone database One offset for the week is wrong for half of it. Files: `src/lib/expand.ts`, `tests/dst.test.ts` 2. Decide what a non-existent local time does Skip it, and write down that you skipped it. Files: `src/lib/expand.ts` 3. Store the invitee zone, do not infer it again later Their browser may be somewhere else by the time you email them. Files: `src/db/schema.sql` ### Done when - [ ] Both transition weeks produce correct instants - [ ] A non-existent local time is skipped - [ ] An ambiguous local time has a documented resolution - [ ] The invitee zone is stored and reused ### Watch out - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. ## Phase 4 · Confirmations, cancellations and reschedules Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost. ### Steps 1. Keep one identifier and raise the sequence Check the current requirement in the calendaring spec your clients read. Files: `src/lib/ics.ts`, `tests/ics-sequence.test.ts` 2. Make reschedule one transaction across both slots Releasing then taking can leave both slots gone. Files: `src/lib/reschedule.ts` 3. Test against two real mail clients They disagree about almost everything in this format. Files: `docs/ics-testing.md` ### Done when - [ ] A reschedule leaves one calendar entry - [ ] The identifier is stable and the sequence rises - [ ] Cancel tokens are single use and leak nothing - [ ] A failed email never loses the booking ### Watch out - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ## Not in this build - Team routing, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. ## After v1, if you want it - A second link type with different duration and buffers, which mostly tests whether your rules really are per link - An availability preview for yourself that shows why a slot is missing, which is the debugging tool you will want first - Recurring bookings, which are worth doing only after the single-booking path has survived a real clock change ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Links, rules, holds and bookings. Back this up; it is the lock. DATABASE_PATH=./data/booking.db # Required. The registered application. Public half of the OAuth pair. CALENDAR_CLIENT_ID=123456789-abc.apps.example.com # Required · secret. Never in the repository, never in a client bundle. CALENDAR_CLIENT_SECRET=secret-...redacted # Required. Used for the callback and in every link you email. Must match the registered callback. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Transactional email. Confirmations, cancellations and reschedules. EMAIL_API_KEY=re_...redacted # Required. How long cached busy intervals are fresh. Past this they are stale, not current. FREEBUSY_TTL_SECONDS=120 # Required. How long the form holds a slot. Long enough to type, short enough not to block. HOLD_MINUTES=10 # Required. After this the name, email and note are removed and the booking stays. INVITEE_RETENTION_DAYS=365
You are building a production product version of Calday. 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 ===== # Calday · product brief ## Problem Calday's solo core is compact: build a personal booking links 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 that cannot double-book because the database refuses, never sees the title of one of your meetings, gets the clock-change week right, and shows nothing at all rather than a free week when the calendar is unreachable. ## 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, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. - 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 - No event title or guest is confirmed to appear in any public response - Concurrent submissions are confirmed to produce one booking - Both daylight saving transition weeks are confirmed correct - A reschedule is confirmed to leave one calendar entry - An availability failure is confirmed to show no slots ===== BRIEF.md ===== # Build brief · Calday The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that publishes real availability from one calendar and writes the booking back without ever double-booking you to replace Calday. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two things, and both are the reason anyone pays for this category at all. It must never double-book you, and the calendar API is not the lock. Two people pressing confirm in the same second will both get an event, because a calendar will happily accept two overlapping entries and tell you nothing. The lock is a unique index in your own database on the link and the slot start. You insert there first; the second insert fails and that person is told the slot went, before anything reaches the calendar at all. And it must never leak your private events. Compute availability from the provider's free/busy query, which returns busy intervals and nothing else: no titles, no guests, no descriptions, no locations. Listing events to work out when you are busy means your booking page is now holding the subject lines of your day, and one templating mistake puts them on a public URL. It also gets the answer wrong, because the calendar's own transparency field decides whether an event blocks time, and free/busy honours that for you while an event list does not. The corollary: when the availability call fails, show no slots and say why. Never treat an error as an empty busy list, because that renders your entire week as free. ### Stack (fixed, do not substitute) - Runtime: Next.js on a host with a stable public URL for the OAuth callback and the booking page · the person booking has to be able to reach it, which rules out running it on your laptop - Store: SQLite with a unique index on (link_id, slot_start_utc) · the database is the lock and the calendar is the record, in that order - Availability: The provider's free/busy query, batched, never a listing of events · it returns intervals only, which is both the correct answer and the private one - Time: Instants in UTC plus the IANA zone name stored beside every rule · an offset is not a timezone and stops being right in March - Notification: One transactional email per booking, sent after the calendar write succeeds · an email about a booking that did not land is worse than no email ### Data model (create this before Phase 1) - links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier. ### Phase 1 · Availability from rules and free/busy Build: Expand the weekly rules and overrides into candidate intervals for a window, subtract the busy intervals returned by the provider's free/busy query, apply buffers, minimum notice and the maximum days ahead, and serve the remainder as slot start times in the invitee's zone. Trap: Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. Done when: The availability response contains start and end times and nothing else, asserted by a test that fails if any event title, guest or description appears in the JSON. An event marked transparent on the source calendar does not remove a slot, tested against a real calendar. Buffers, minimum notice and maximum days ahead each change the slot list in a test that sets them to zero and then to a value. Calendars are batched under the provider's documented expansion limit rather than sent in one request. Do not build yet: No booking, no holds, no email and no calendar writes yet. ### Phase 2 · A slot that can only be taken once Build: Take a hold with an expiry when the form opens, and show the countdown. On submit, insert the booking under the unique index inside a transaction, and only then write the calendar event. A calendar write that fails leaves the booking in a needs-attention state, not deleted. Trap: Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. Done when: Two concurrent submissions for the same slot result in exactly one booking row and one calendar event, tested with parallel requests. The losing request gets a specific message and alternative slots, not a generic failure. A hold expires and the slot becomes bookable again, tested by moving the clock. A calendar write failure leaves the booking row present and flagged, and the invitee is told the booking is confirmed but not yet on the calendar. Do not build yet: No timezone rendering for the invitee, no emails and no cancellation yet. ### Phase 3 · Timezones that survive the clock change Build: Render the picker in the invitee's detected zone with an explicit selector. Expand each rule day by day through the zone database rather than by applying one computed offset to the week. Handle local times that do not exist and local times that happen twice. Trap: Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. Done when: A rule of nine to five in a zone with daylight saving produces correct UTC instants on both sides of a transition, tested for both the spring and autumn weeks. A local time that does not exist on a transition day is skipped rather than shifted silently, tested. A local time that occurs twice resolves to a documented choice and that choice is stated in the code. The invitee's zone is stored with the booking and used in every message sent to them. Do not build yet: No emails, calendar invitations, cancellations or reschedules yet. ### Phase 4 · Confirmations, cancellations and reschedules Build: Send one confirmation with a calendar attachment carrying a stable unique identifier. Provide cancel and reschedule links carrying a per-booking token. A reschedule releases the old slot and takes the new one in one transaction, and updates rather than replaces the calendar entry. Trap: Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. Done when: A reschedule produces one entry in the invitee's calendar, not two, verified in at least two mail clients. The calendar attachment keeps the same unique identifier across confirm, reschedule and cancel, with an increasing sequence, asserted in the suite. A cancel link is single use and invalid tokens fail without revealing whether the booking exists. Email sending failures are retried and surfaced, and never cause the booking itself to be lost. Do not build yet: No token refresh, retention or export yet. ### Phase 5 · Tokens, failure and the invitee's data Build: Refresh the calendar token ahead of expiry and warn when refresh fails. When availability cannot be fetched, show no slots with a plain explanation. Give invitee records a retention period, provide a delete-by-email function, and export everything as CSV plus calendar files. Trap: Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. Done when: A forced availability failure results in zero slots and an explanatory message, tested by making the provider call throw. A cache older than its TTL is marked stale and is never served as current availability. Invitee records past the retention period are deleted and the bookings survive as anonymised rows, tested. A delete-by-email request removes that person from bookings, holds and email logs, verified by searching the database for the address afterwards. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which needs several people's calendars and a fairness rule, and is the feature the paid tiers exist for. - Taking payment at booking, which brings refunds, disputes and tax handling into what is otherwise a weekend build. - Text message reminders, which need a provider, a sender registration and a per-message cost for something email already does. - Writing to more than one calendar provider, because each has its own recurrence and free/busy quirks and two is more than twice the work of one. ### README must contain - That the unique index on link and slot start is the double-booking defence, and the calendar is not - That availability comes from free/busy so the page never sees your event titles, and why an event listing would be both less private and less correct - That a failed availability fetch shows no slots on purpose, and how to tell that from having no availability - That rules are stored as local wall time plus an IANA zone name, and which transition weeks are covered by tests - Where invitee personal data lives, how long it is kept, and how to delete one person entirely ===== ARCHITECTURE.md ===== # Architecture · Calday ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js on a host with a stable public URL for the OAuth callback and the booking page | the person booking has to be able to reach it, which rules out running it on your laptop | | Store | SQLite with a unique index on (link_id, slot_start_utc) | the database is the lock and the calendar is the record, in that order | | Availability | The provider's free/busy query, batched, never a listing of events | it returns intervals only, which is both the correct answer and the private one | | Time | Instants in UTC plus the IANA zone name stored beside every rule | an offset is not a timezone and stops being right in March | | Notification | One transactional email per booking, sent after the calendar write succeeds | an email about a booking that did not land is worse than no email | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Rules | Weekly availability, overrides, buffers and horizon. | Any rule engine, provided local time and zone are stored separately. | | Availability | Free/busy fetching, caching, staleness. | Any provider, provided it returns intervals rather than events. | | Booking | Holds, the unique index, calendar writes. | Not swappable for calendar-first booking, which has no lock. | | Messaging | Email and calendar attachments with stable identifiers. | Any provider, provided the identifier survives a reschedule. | | Retention | Invitee data expiry and erasure. | Any policy, provided one function covers every table. | ## 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 · Links, rules, holds and bookings. Back this up; it is the lock. - `CALENDAR_CLIENT_ID` · required · The registered application. Public half of the OAuth pair. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository, never in a client bundle. - `PUBLIC_BASE_URL` · required · Used for the callback and in every link you email. Must match the registered callback. - `EMAIL_API_KEY` · required, secret · Transactional email. Confirmations, cancellations and reschedules. - `FREEBUSY_TTL_SECONDS` · required · How long cached busy intervals are fresh. Past this they are stale, not current. - `HOLD_MINUTES` · required · How long the form holds a slot. Long enough to type, short enough not to block. - `INVITEE_RETENTION_DAYS` · required · After this the name, email and note are removed and the booking stays. ## 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 · Calday product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js on a host with a stable public URL for the OAuth callback and the booking page, SQLite with a unique index on (link_id, slot_start_utc), The provider's free/busy query, batched, never a listing of events, Instants in UTC plus the IANA zone name stored beside every rule, One transactional email per booking, sent after the calendar write succeeds. - 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 - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. - Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. ===== MILESTONES.md ===== # Delivery milestones · Calday Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability from rules and free/busy A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response. ### Steps 1. Use free/busy, never an event listing It is the private answer and it is also the correct one. Files: `src/lib/availability.ts`, `tests/no-event-details.test.ts` 2. Expand rules to intervals, then subtract busy Two clear steps beat one clever query. Files: `src/lib/rules.ts`, `tests/rules.test.ts` 3. Batch calendars under the documented expansion cap Check the current cap; do not copy this number from a blog post. Files: `src/lib/freebusy.ts` ### Done when - [ ] No event title or guest can appear in the response - [ ] A transparent event does not block a slot - [ ] Buffers, notice and horizon each change the slot list - [ ] Calendars are batched under the documented cap ### Watch out - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. ## M2 · A slot that can only be taken once Two simultaneous bookings for the same slot end with one confirmation and one clear refusal. ### Steps 1. Insert under the unique index before touching the calendar The database is the only lock in this system. Files: `src/lib/book.ts`, `src/db/schema.sql` 2. Hold the slot when the form opens, with an expiry It turns a race into a rare case rather than a common one. Files: `src/lib/holds.ts`, `tests/holds.test.ts` 3. Make a failed calendar write a state, not a rollback The person booked; losing their booking is the worse outcome. Files: `src/lib/book.ts` ### Done when - [ ] Concurrent submissions produce one booking - [ ] The loser is told, with alternatives - [ ] An expired hold frees the slot - [ ] A failed calendar write flags rather than deletes ### Watch out - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. ## M3 · Timezones that survive the clock change A weekly rule renders correctly for both parties through a daylight saving transition. ### Steps 1. Expand rules per day through the zone database One offset for the week is wrong for half of it. Files: `src/lib/expand.ts`, `tests/dst.test.ts` 2. Decide what a non-existent local time does Skip it, and write down that you skipped it. Files: `src/lib/expand.ts` 3. Store the invitee zone, do not infer it again later Their browser may be somewhere else by the time you email them. Files: `src/db/schema.sql` ### Done when - [ ] Both transition weeks produce correct instants - [ ] A non-existent local time is skipped - [ ] An ambiguous local time has a documented resolution - [ ] The invitee zone is stored and reused ### Watch out - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. ## M4 · Confirmations, cancellations and reschedules Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost. ### Steps 1. Keep one identifier and raise the sequence Check the current requirement in the calendaring spec your clients read. Files: `src/lib/ics.ts`, `tests/ics-sequence.test.ts` 2. Make reschedule one transaction across both slots Releasing then taking can leave both slots gone. Files: `src/lib/reschedule.ts` 3. Test against two real mail clients They disagree about almost everything in this format. Files: `docs/ics-testing.md` ### Done when - [ ] A reschedule leaves one calendar entry - [ ] The identifier is stable and the sequence rises - [ ] Cancel tokens are single use and leak nothing - [ ] A failed email never loses the booking ### Watch out - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ## M5 · Tokens, failure and the invitee's data (production only) The page fails closed when the calendar is unreachable, and the personal data it collects has an end date. ### Steps 1. Fail closed on an availability error No slots and a reason, never a week of open time. Files: `src/lib/availability.ts`, `tests/fail-closed.test.ts` 2. Expire invitee details, keep the booking shape You need the history; you do not need their phone number forever. Files: `src/lib/retention.ts` 3. Write one delete-by-email that covers every table Then assert the address appears nowhere. Files: `src/lib/erase.ts`, `tests/erase.test.ts` ### Done when - [ ] An availability failure shows no slots and says why - [ ] A stale cache is never served as current - [ ] Retention deletes invitee details and keeps the booking - [ ] Delete-by-email leaves no trace of the address ### Watch out - Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. ===== OPERATIONS.md ===== # Operations · Calday ## Backup The SQLite file, hourly, offsite. It holds the bookings and the unique index that prevents double booking, and the calendar is a copy of the outcome rather than a backup of the decision. ## Restore Restore the database, confirm the unique index exists before accepting traffic, then re-authorise the calendar. A restore that drops the index silently turns the double-booking defence off, so check for it explicitly rather than assuming the schema came back intact. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Availability fetch failures, bookings in the needs-attention state, token refresh failures and the age of the free/busy cache. The alarm that matters is a run of failed availability fetches, because that is the page showing nothing to everybody who visits. ## Incident checklist A double booking is the incident. Reconstruct from the bookings table and the calendar event ids, apologise to the second person yourself rather than by automation, and then work out whether the index was missing, the hold expired early or someone created the event directly in the calendar, because those are three different fixes. 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 - [ ] No event title or guest is confirmed to appear in any public response - [ ] Concurrent submissions are confirmed to produce one booking - [ ] Both daylight saving transition weeks are confirmed correct - [ ] A reschedule is confirmed to leave one calendar entry - [ ] An availability failure is confirmed to show no slots ## Launch constraint Do not market omitted Calday 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. Links, rules, holds and bookings. Back this up; it is the lock. DATABASE_PATH=./data/booking.db # Required. The registered application. Public half of the OAuth pair. CALENDAR_CLIENT_ID=123456789-abc.apps.example.com # Required · secret. Never in the repository, never in a client bundle. CALENDAR_CLIENT_SECRET=secret-...redacted # Required. Used for the callback and in every link you email. Must match the registered callback. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Transactional email. Confirmations, cancellations and reschedules. EMAIL_API_KEY=re_...redacted # Required. How long cached busy intervals are fresh. Past this they are stale, not current. FREEBUSY_TTL_SECONDS=120 # Required. How long the form holds a slot. Long enough to type, short enough not to block. HOLD_MINUTES=10 # Required. After this the name, email and note are removed and the booking stays. INVITEE_RETENTION_DAYS=365
# Calday · indie build A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js on a host with a stable public URL for the OAuth callback and the booking page | the person booking has to be able to reach it, which rules out running it on your laptop | | Store | SQLite with a unique index on (link_id, slot_start_utc) | the database is the lock and the calendar is the record, in that order | | Availability | The provider's free/busy query, batched, never a listing of events | it returns intervals only, which is both the correct answer and the private one | | Time | Instants in UTC plus the IANA zone name stored beside every rule | an offset is not a timezone and stops being right in March | | Notification | One transactional email per booking, sent after the calendar write succeeds | an email about a booking that did not land is worse than no email | ## 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 a version manager. - Verify: node --version prints v22 or higher - [ ] **Calendar API credentials with a free/busy scope** · free - Why: Free/busy is a narrower permission than reading events, and asking for the narrower one is the difference between a booking page that cannot leak your day and one that can. - Get it: Create a project in the provider's console, configure the consent screen and register the callback URL. - Verify: A free/busy call returns intervals and a call for event details is refused by the scope - [ ] **A public HTTPS URL for the callback and the booking page** · a few pounds a month, or free on a small tier - Why: The OAuth callback and the person booking both need to reach it, so a laptop behind a router will not do once this is real. - Get it: A small host or a tunnel with a stable hostname. - Verify: The callback completes and the booking page loads from another network - [ ] **A transactional email provider** · free at personal volume - Why: Confirmations and cancellations are the part people notice, and sending them from a home address means they land in spam. - Get it: Any provider with an API and a verified sending domain. - Verify: A test confirmation with a calendar attachment arrives and the attachment opens - [ ] **Your zone, and a second one to test against** · free - Why: Daylight saving bugs only appear on transition weeks and only in zones that have them, so pick the two zones your tests will cover before writing the expansion code. - Get it: Choose your own zone plus one in the opposite hemisphere. - Verify: Both appear in the test suite with a spring and an autumn case ## 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, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. - 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 Calday, and the README should say so rather than pretend.
# Build brief · Calday The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that publishes real availability from one calendar and writes the booking back without ever double-booking you to replace Calday. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two things, and both are the reason anyone pays for this category at all. It must never double-book you, and the calendar API is not the lock. Two people pressing confirm in the same second will both get an event, because a calendar will happily accept two overlapping entries and tell you nothing. The lock is a unique index in your own database on the link and the slot start. You insert there first; the second insert fails and that person is told the slot went, before anything reaches the calendar at all. And it must never leak your private events. Compute availability from the provider's free/busy query, which returns busy intervals and nothing else: no titles, no guests, no descriptions, no locations. Listing events to work out when you are busy means your booking page is now holding the subject lines of your day, and one templating mistake puts them on a public URL. It also gets the answer wrong, because the calendar's own transparency field decides whether an event blocks time, and free/busy honours that for you while an event list does not. The corollary: when the availability call fails, show no slots and say why. Never treat an error as an empty busy list, because that renders your entire week as free. ### Stack (fixed, do not substitute) - Runtime: Next.js on a host with a stable public URL for the OAuth callback and the booking page · the person booking has to be able to reach it, which rules out running it on your laptop - Store: SQLite with a unique index on (link_id, slot_start_utc) · the database is the lock and the calendar is the record, in that order - Availability: The provider's free/busy query, batched, never a listing of events · it returns intervals only, which is both the correct answer and the private one - Time: Instants in UTC plus the IANA zone name stored beside every rule · an offset is not a timezone and stops being right in March - Notification: One transactional email per booking, sent after the calendar write succeeds · an email about a booking that did not land is worse than no email ### Data model (create this before Phase 1) - links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier. ### Phase 1 · Availability from rules and free/busy Build: Expand the weekly rules and overrides into candidate intervals for a window, subtract the busy intervals returned by the provider's free/busy query, apply buffers, minimum notice and the maximum days ahead, and serve the remainder as slot start times in the invitee's zone. Trap: Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. Done when: The availability response contains start and end times and nothing else, asserted by a test that fails if any event title, guest or description appears in the JSON. An event marked transparent on the source calendar does not remove a slot, tested against a real calendar. Buffers, minimum notice and maximum days ahead each change the slot list in a test that sets them to zero and then to a value. Calendars are batched under the provider's documented expansion limit rather than sent in one request. Do not build yet: No booking, no holds, no email and no calendar writes yet. ### Phase 2 · A slot that can only be taken once Build: Take a hold with an expiry when the form opens, and show the countdown. On submit, insert the booking under the unique index inside a transaction, and only then write the calendar event. A calendar write that fails leaves the booking in a needs-attention state, not deleted. Trap: Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. Done when: Two concurrent submissions for the same slot result in exactly one booking row and one calendar event, tested with parallel requests. The losing request gets a specific message and alternative slots, not a generic failure. A hold expires and the slot becomes bookable again, tested by moving the clock. A calendar write failure leaves the booking row present and flagged, and the invitee is told the booking is confirmed but not yet on the calendar. Do not build yet: No timezone rendering for the invitee, no emails and no cancellation yet. ### Phase 3 · Timezones that survive the clock change Build: Render the picker in the invitee's detected zone with an explicit selector. Expand each rule day by day through the zone database rather than by applying one computed offset to the week. Handle local times that do not exist and local times that happen twice. Trap: Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. Done when: A rule of nine to five in a zone with daylight saving produces correct UTC instants on both sides of a transition, tested for both the spring and autumn weeks. A local time that does not exist on a transition day is skipped rather than shifted silently, tested. A local time that occurs twice resolves to a documented choice and that choice is stated in the code. The invitee's zone is stored with the booking and used in every message sent to them. Do not build yet: No emails, calendar invitations, cancellations or reschedules yet. ### Phase 4 · Confirmations, cancellations and reschedules Build: Send one confirmation with a calendar attachment carrying a stable unique identifier. Provide cancel and reschedule links carrying a per-booking token. A reschedule releases the old slot and takes the new one in one transaction, and updates rather than replaces the calendar entry. Trap: Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. Done when: A reschedule produces one entry in the invitee's calendar, not two, verified in at least two mail clients. The calendar attachment keeps the same unique identifier across confirm, reschedule and cancel, with an increasing sequence, asserted in the suite. A cancel link is single use and invalid tokens fail without revealing whether the booking exists. Email sending failures are retried and surfaced, and never cause the booking itself to be lost. Do not build yet: No token refresh, retention or export yet. ### Phase 5 · Tokens, failure and the invitee's data Build: Refresh the calendar token ahead of expiry and warn when refresh fails. When availability cannot be fetched, show no slots with a plain explanation. Give invitee records a retention period, provide a delete-by-email function, and export everything as CSV plus calendar files. Trap: Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. Done when: A forced availability failure results in zero slots and an explanatory message, tested by making the provider call throw. A cache older than its TTL is marked stale and is never served as current availability. Invitee records past the retention period are deleted and the bookings survive as anonymised rows, tested. A delete-by-email request removes that person from bookings, holds and email logs, verified by searching the database for the address afterwards. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which needs several people's calendars and a fairness rule, and is the feature the paid tiers exist for. - Taking payment at booking, which brings refunds, disputes and tax handling into what is otherwise a weekend build. - Text message reminders, which need a provider, a sender registration and a per-message cost for something email already does. - Writing to more than one calendar provider, because each has its own recurrence and free/busy quirks and two is more than twice the work of one. ### README must contain - That the unique index on link and slot start is the double-booking defence, and the calendar is not - That availability comes from free/busy so the page never sees your event titles, and why an event listing would be both less private and less correct - That a failed availability fetch shows no slots on purpose, and how to tell that from having no availability - That rules are stored as local wall time plus an IANA zone name, and which transition weeks are covered by tests - Where invitee personal data lives, how long it is kept, and how to delete one person entirely
# Agent instructions · Calday indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Next.js on a host with a stable public URL for the OAuth callback and the booking page, SQLite with a unique index on (link_id, slot_start_utc), The provider's free/busy query, batched, never a listing of events, Instants in UTC plus the IANA zone name stored beside every rule, One transactional email per booking, sent after the calendar write succeeds. 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 - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree.
# Build plan · Calday A personal booking page whose hard problems are holding a slot so two people cannot take it, computing availability without ever reading your event titles, and expanding a weekly rule through a timezone so the week of a clock change does not send everybody an hour wrong. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Availability from rules and free/busy A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response. ### Steps 1. Use free/busy, never an event listing It is the private answer and it is also the correct one. Files: `src/lib/availability.ts`, `tests/no-event-details.test.ts` 2. Expand rules to intervals, then subtract busy Two clear steps beat one clever query. Files: `src/lib/rules.ts`, `tests/rules.test.ts` 3. Batch calendars under the documented expansion cap Check the current cap; do not copy this number from a blog post. Files: `src/lib/freebusy.ts` ### Done when - [ ] No event title or guest can appear in the response - [ ] A transparent event does not block a slot - [ ] Buffers, notice and horizon each change the slot list - [ ] Calendars are batched under the documented cap ### Watch out - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. ## Phase 2 · A slot that can only be taken once Two simultaneous bookings for the same slot end with one confirmation and one clear refusal. ### Steps 1. Insert under the unique index before touching the calendar The database is the only lock in this system. Files: `src/lib/book.ts`, `src/db/schema.sql` 2. Hold the slot when the form opens, with an expiry It turns a race into a rare case rather than a common one. Files: `src/lib/holds.ts`, `tests/holds.test.ts` 3. Make a failed calendar write a state, not a rollback The person booked; losing their booking is the worse outcome. Files: `src/lib/book.ts` ### Done when - [ ] Concurrent submissions produce one booking - [ ] The loser is told, with alternatives - [ ] An expired hold frees the slot - [ ] A failed calendar write flags rather than deletes ### Watch out - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. ## Phase 3 · Timezones that survive the clock change A weekly rule renders correctly for both parties through a daylight saving transition. ### Steps 1. Expand rules per day through the zone database One offset for the week is wrong for half of it. Files: `src/lib/expand.ts`, `tests/dst.test.ts` 2. Decide what a non-existent local time does Skip it, and write down that you skipped it. Files: `src/lib/expand.ts` 3. Store the invitee zone, do not infer it again later Their browser may be somewhere else by the time you email them. Files: `src/db/schema.sql` ### Done when - [ ] Both transition weeks produce correct instants - [ ] A non-existent local time is skipped - [ ] An ambiguous local time has a documented resolution - [ ] The invitee zone is stored and reused ### Watch out - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. ## Phase 4 · Confirmations, cancellations and reschedules Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost. ### Steps 1. Keep one identifier and raise the sequence Check the current requirement in the calendaring spec your clients read. Files: `src/lib/ics.ts`, `tests/ics-sequence.test.ts` 2. Make reschedule one transaction across both slots Releasing then taking can leave both slots gone. Files: `src/lib/reschedule.ts` 3. Test against two real mail clients They disagree about almost everything in this format. Files: `docs/ics-testing.md` ### Done when - [ ] A reschedule leaves one calendar entry - [ ] The identifier is stable and the sequence rises - [ ] Cancel tokens are single use and leak nothing - [ ] A failed email never loses the booking ### Watch out - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ## Not in this build - Team routing, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. ## After v1, if you want it - A second link type with different duration and buffers, which mostly tests whether your rules really are per link - An availability preview for yourself that shows why a slot is missing, which is the debugging tool you will want first - Recurring bookings, which are worth doing only after the single-booking path has survived a real clock change
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Links, rules, holds and bookings. Back this up; it is the lock. DATABASE_PATH=./data/booking.db # Required. The registered application. Public half of the OAuth pair. CALENDAR_CLIENT_ID=123456789-abc.apps.example.com # Required · secret. Never in the repository, never in a client bundle. CALENDAR_CLIENT_SECRET=secret-...redacted # Required. Used for the callback and in every link you email. Must match the registered callback. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Transactional email. Confirmations, cancellations and reschedules. EMAIL_API_KEY=re_...redacted # Required. How long cached busy intervals are fresh. Past this they are stale, not current. FREEBUSY_TTL_SECONDS=120 # Required. How long the form holds a slot. Long enough to type, short enough not to block. HOLD_MINUTES=10 # Required. After this the name, email and note are removed and the booking stays. INVITEE_RETENTION_DAYS=365
# Calday · product brief ## Problem Calday's solo core is compact: build a personal booking links 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 that cannot double-book because the database refuses, never sees the title of one of your meetings, gets the clock-change week right, and shows nothing at all rather than a free week when the calendar is unreachable. ## 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, round robin and collective availability. - Payment at booking. - Text message reminders. - More than one calendar provider. - 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 - No event title or guest is confirmed to appear in any public response - Concurrent submissions are confirmed to produce one booking - Both daylight saving transition weeks are confirmed correct - A reschedule is confirmed to leave one calendar entry - An availability failure is confirmed to show no slots
# Build brief · Calday The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that publishes real availability from one calendar and writes the booking back without ever double-booking you to replace Calday. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two things, and both are the reason anyone pays for this category at all. It must never double-book you, and the calendar API is not the lock. Two people pressing confirm in the same second will both get an event, because a calendar will happily accept two overlapping entries and tell you nothing. The lock is a unique index in your own database on the link and the slot start. You insert there first; the second insert fails and that person is told the slot went, before anything reaches the calendar at all. And it must never leak your private events. Compute availability from the provider's free/busy query, which returns busy intervals and nothing else: no titles, no guests, no descriptions, no locations. Listing events to work out when you are busy means your booking page is now holding the subject lines of your day, and one templating mistake puts them on a public URL. It also gets the answer wrong, because the calendar's own transparency field decides whether an event blocks time, and free/busy honours that for you while an event list does not. The corollary: when the availability call fails, show no slots and say why. Never treat an error as an empty busy list, because that renders your entire week as free. ### Stack (fixed, do not substitute) - Runtime: Next.js on a host with a stable public URL for the OAuth callback and the booking page · the person booking has to be able to reach it, which rules out running it on your laptop - Store: SQLite with a unique index on (link_id, slot_start_utc) · the database is the lock and the calendar is the record, in that order - Availability: The provider's free/busy query, batched, never a listing of events · it returns intervals only, which is both the correct answer and the private one - Time: Instants in UTC plus the IANA zone name stored beside every rule · an offset is not a timezone and stops being right in March - Notification: One transactional email per booking, sent after the calendar write succeeds · an email about a booking that did not land is worse than no email ### Data model (create this before Phase 1) - links(id, slug, title, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, calendar_ids_json, owner_timezone, active) - rules(id, link_id, weekday, start_local, end_local, timezone, effective_from, effective_to) · local wall time plus a zone name, never an offset - overrides(id, link_id, date_local, kind, start_local, end_local) · a day off, or extra hours, expressed the same way - holds(id, link_id, slot_start_utc, token, created_at, expires_at) · a short reservation taken when the form opens, so two people do not both reach submit - bookings(id, link_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, note, state, calendar_event_id, ical_uid, sequence, created_at) - freebusy_cache(calendar_id, window_start, window_end, busy_json, fetched_at, ttl_seconds, stale) · cached intervals with an explicit stale flag, because a stale cache must not be silently trusted - The unique index on (link_id, slot_start_utc) in bookings is the entire double-booking defence. Everything above it, including the holds table, is an optimisation that makes the failure rarer and friendlier. ### Phase 1 · Availability from rules and free/busy Build: Expand the weekly rules and overrides into candidate intervals for a window, subtract the busy intervals returned by the provider's free/busy query, apply buffers, minimum notice and the maximum days ahead, and serve the remainder as slot start times in the invitee's zone. Trap: Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. Done when: The availability response contains start and end times and nothing else, asserted by a test that fails if any event title, guest or description appears in the JSON. An event marked transparent on the source calendar does not remove a slot, tested against a real calendar. Buffers, minimum notice and maximum days ahead each change the slot list in a test that sets them to zero and then to a value. Calendars are batched under the provider's documented expansion limit rather than sent in one request. Do not build yet: No booking, no holds, no email and no calendar writes yet. ### Phase 2 · A slot that can only be taken once Build: Take a hold with an expiry when the form opens, and show the countdown. On submit, insert the booking under the unique index inside a transaction, and only then write the calendar event. A calendar write that fails leaves the booking in a needs-attention state, not deleted. Trap: Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. Done when: Two concurrent submissions for the same slot result in exactly one booking row and one calendar event, tested with parallel requests. The losing request gets a specific message and alternative slots, not a generic failure. A hold expires and the slot becomes bookable again, tested by moving the clock. A calendar write failure leaves the booking row present and flagged, and the invitee is told the booking is confirmed but not yet on the calendar. Do not build yet: No timezone rendering for the invitee, no emails and no cancellation yet. ### Phase 3 · Timezones that survive the clock change Build: Render the picker in the invitee's detected zone with an explicit selector. Expand each rule day by day through the zone database rather than by applying one computed offset to the week. Handle local times that do not exist and local times that happen twice. Trap: Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. Done when: A rule of nine to five in a zone with daylight saving produces correct UTC instants on both sides of a transition, tested for both the spring and autumn weeks. A local time that does not exist on a transition day is skipped rather than shifted silently, tested. A local time that occurs twice resolves to a documented choice and that choice is stated in the code. The invitee's zone is stored with the booking and used in every message sent to them. Do not build yet: No emails, calendar invitations, cancellations or reschedules yet. ### Phase 4 · Confirmations, cancellations and reschedules Build: Send one confirmation with a calendar attachment carrying a stable unique identifier. Provide cancel and reschedule links carrying a per-booking token. A reschedule releases the old slot and takes the new one in one transaction, and updates rather than replaces the calendar entry. Trap: Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. Done when: A reschedule produces one entry in the invitee's calendar, not two, verified in at least two mail clients. The calendar attachment keeps the same unique identifier across confirm, reschedule and cancel, with an increasing sequence, asserted in the suite. A cancel link is single use and invalid tokens fail without revealing whether the booking exists. Email sending failures are retried and surfaced, and never cause the booking itself to be lost. Do not build yet: No token refresh, retention or export yet. ### Phase 5 · Tokens, failure and the invitee's data Build: Refresh the calendar token ahead of expiry and warn when refresh fails. When availability cannot be fetched, show no slots with a plain explanation. Give invitee records a retention period, provide a delete-by-email function, and export everything as CSV plus calendar files. Trap: Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week. Done when: A forced availability failure results in zero slots and an explanatory message, tested by making the provider call throw. A cache older than its TTL is marked stale and is never served as current availability. Invitee records past the retention period are deleted and the bookings survive as anonymised rows, tested. A delete-by-email request removes that person from bookings, holds and email logs, verified by searching the database for the address afterwards. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which needs several people's calendars and a fairness rule, and is the feature the paid tiers exist for. - Taking payment at booking, which brings refunds, disputes and tax handling into what is otherwise a weekend build. - Text message reminders, which need a provider, a sender registration and a per-message cost for something email already does. - Writing to more than one calendar provider, because each has its own recurrence and free/busy quirks and two is more than twice the work of one. ### README must contain - That the unique index on link and slot start is the double-booking defence, and the calendar is not - That availability comes from free/busy so the page never sees your event titles, and why an event listing would be both less private and less correct - That a failed availability fetch shows no slots on purpose, and how to tell that from having no availability - That rules are stored as local wall time plus an IANA zone name, and which transition weeks are covered by tests - Where invitee personal data lives, how long it is kept, and how to delete one person entirely
# Architecture · Calday ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Next.js on a host with a stable public URL for the OAuth callback and the booking page | the person booking has to be able to reach it, which rules out running it on your laptop | | Store | SQLite with a unique index on (link_id, slot_start_utc) | the database is the lock and the calendar is the record, in that order | | Availability | The provider's free/busy query, batched, never a listing of events | it returns intervals only, which is both the correct answer and the private one | | Time | Instants in UTC plus the IANA zone name stored beside every rule | an offset is not a timezone and stops being right in March | | Notification | One transactional email per booking, sent after the calendar write succeeds | an email about a booking that did not land is worse than no email | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Rules | Weekly availability, overrides, buffers and horizon. | Any rule engine, provided local time and zone are stored separately. | | Availability | Free/busy fetching, caching, staleness. | Any provider, provided it returns intervals rather than events. | | Booking | Holds, the unique index, calendar writes. | Not swappable for calendar-first booking, which has no lock. | | Messaging | Email and calendar attachments with stable identifiers. | Any provider, provided the identifier survives a reschedule. | | Retention | Invitee data expiry and erasure. | Any policy, provided one function covers every table. | ## 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 · Links, rules, holds and bookings. Back this up; it is the lock. - `CALENDAR_CLIENT_ID` · required · The registered application. Public half of the OAuth pair. - `CALENDAR_CLIENT_SECRET` · required, secret · Never in the repository, never in a client bundle. - `PUBLIC_BASE_URL` · required · Used for the callback and in every link you email. Must match the registered callback. - `EMAIL_API_KEY` · required, secret · Transactional email. Confirmations, cancellations and reschedules. - `FREEBUSY_TTL_SECONDS` · required · How long cached busy intervals are fresh. Past this they are stale, not current. - `HOLD_MINUTES` · required · How long the form holds a slot. Long enough to type, short enough not to block. - `INVITEE_RETENTION_DAYS` · required · After this the name, email and note are removed and the booking stays. ## 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 · Calday product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Next.js on a host with a stable public URL for the OAuth callback and the booking page, SQLite with a unique index on (link_id, slot_start_utc), The provider's free/busy query, batched, never a listing of events, Instants in UTC plus the IANA zone name stored beside every rule, One transactional email per booking, sent after the calendar write succeeds. - 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 - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. - Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week.
# Delivery milestones · Calday Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Availability from rules and free/busy A public page shows slots computed from your rules minus your real busy time, with no event details anywhere in the response. ### Steps 1. Use free/busy, never an event listing It is the private answer and it is also the correct one. Files: `src/lib/availability.ts`, `tests/no-event-details.test.ts` 2. Expand rules to intervals, then subtract busy Two clear steps beat one clever query. Files: `src/lib/rules.ts`, `tests/rules.test.ts` 3. Batch calendars under the documented expansion cap Check the current cap; do not copy this number from a blog post. Files: `src/lib/freebusy.ts` ### Done when - [ ] No event title or guest can appear in the response - [ ] A transparent event does not block a slot - [ ] Buffers, notice and horizon each change the slot list - [ ] Calendars are batched under the documented cap ### Watch out - Working out busy time by listing events gets the answer wrong twice over, because a calendar event carries a transparency field whose values decide whether it blocks time and an event set to transparent does not, while cancelled and declined entries are still in the list; the free/busy query applies that rule for you and hands back intervals with no titles attached, and it caps how many calendars one request will expand at a documented maximum, so batch your calendars rather than sending fifty in one call. ## M2 · A slot that can only be taken once Two simultaneous bookings for the same slot end with one confirmation and one clear refusal. ### Steps 1. Insert under the unique index before touching the calendar The database is the only lock in this system. Files: `src/lib/book.ts`, `src/db/schema.sql` 2. Hold the slot when the form opens, with an expiry It turns a race into a rare case rather than a common one. Files: `src/lib/holds.ts`, `tests/holds.test.ts` 3. Make a failed calendar write a state, not a rollback The person booked; losing their booking is the worse outcome. Files: `src/lib/book.ts` ### Done when - [ ] Concurrent submissions produce one booking - [ ] The loser is told, with alternatives - [ ] An expired hold frees the slot - [ ] A failed calendar write flags rather than deletes ### Watch out - Creating the calendar event first and treating a successful response as the booking means two people who submit within the same second both get an event and both get a confirmation email, because nothing in the calendar API is a mutual exclusion; insert into your own table first, let the unique index reject the second one, and make the losing path a polite message with the next three free slots rather than a five hundred error. ## M3 · Timezones that survive the clock change A weekly rule renders correctly for both parties through a daylight saving transition. ### Steps 1. Expand rules per day through the zone database One offset for the week is wrong for half of it. Files: `src/lib/expand.ts`, `tests/dst.test.ts` 2. Decide what a non-existent local time does Skip it, and write down that you skipped it. Files: `src/lib/expand.ts` 3. Store the invitee zone, do not infer it again later Their browser may be somewhere else by the time you email them. Files: `src/db/schema.sql` ### Done when - [ ] Both transition weeks produce correct instants - [ ] A non-existent local time is skipped - [ ] An ambiguous local time has a documented resolution - [ ] The invitee zone is stored and reused ### Watch out - Computing today's UTC offset for your zone and adding it to every other day in the window shifts every slot after a clock change by an hour, which means a week of meetings sent out an hour wrong to people who then turn up an hour wrong, and you find out from them; expand each day separately through a zone-aware library, and write a test for the week containing each transition in your own zone and in one southern hemisphere zone. ## M4 · Confirmations, cancellations and reschedules Both calendars agree after a reschedule, and a cancellation removes the event rather than leaving a ghost. ### Steps 1. Keep one identifier and raise the sequence Check the current requirement in the calendaring spec your clients read. Files: `src/lib/ics.ts`, `tests/ics-sequence.test.ts` 2. Make reschedule one transaction across both slots Releasing then taking can leave both slots gone. Files: `src/lib/reschedule.ts` 3. Test against two real mail clients They disagree about almost everything in this format. Files: `docs/ics-testing.md` ### Done when - [ ] A reschedule leaves one calendar entry - [ ] The identifier is stable and the sequence rises - [ ] Cancel tokens are single use and leak nothing - [ ] A failed email never loses the booking ### Watch out - Sending a fresh calendar attachment with a new identifier when somebody reschedules leaves the old meeting sitting in their calendar next to the new one, and they will attend the wrong one; keep the same unique identifier for the life of the booking, raise the sequence number on each change the way the calendaring format your clients read requires, and check the behaviour against at least two mail clients because they disagree. ## M5 · Tokens, failure and the invitee's data (production only) The page fails closed when the calendar is unreachable, and the personal data it collects has an end date. ### Steps 1. Fail closed on an availability error No slots and a reason, never a week of open time. Files: `src/lib/availability.ts`, `tests/fail-closed.test.ts` 2. Expire invitee details, keep the booking shape You need the history; you do not need their phone number forever. Files: `src/lib/retention.ts` 3. Write one delete-by-email that covers every table Then assert the address appears nowhere. Files: `src/lib/erase.ts`, `tests/erase.test.ts` ### Done when - [ ] An availability failure shows no slots and says why - [ ] A stale cache is never served as current - [ ] Retention deletes invitee details and keeps the booking - [ ] Delete-by-email leaves no trace of the address ### Watch out - Treating a failed availability call as an empty list of busy intervals publishes your entire week as free, and the booking tool whose whole job is preventing double bookings becomes the thing that causes them; make the failure path explicit, mark the cache stale rather than serving it as fresh, and show a page that says availability is temporarily unavailable rather than a page that says you are free all week.
# Operations · Calday ## Backup The SQLite file, hourly, offsite. It holds the bookings and the unique index that prevents double booking, and the calendar is a copy of the outcome rather than a backup of the decision. ## Restore Restore the database, confirm the unique index exists before accepting traffic, then re-authorise the calendar. A restore that drops the index silently turns the double-booking defence off, so check for it explicitly rather than assuming the schema came back intact. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Availability fetch failures, bookings in the needs-attention state, token refresh failures and the age of the free/busy cache. The alarm that matters is a run of failed availability fetches, because that is the page showing nothing to everybody who visits. ## Incident checklist A double booking is the incident. Reconstruct from the bookings table and the calendar event ids, apologise to the second person yourself rather than by automation, and then work out whether the index was missing, the hold expired early or someone created the event directly in the calendar, because those are three different fixes. 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 - [ ] No event title or guest is confirmed to appear in any public response - [ ] Concurrent submissions are confirmed to produce one booking - [ ] Both daylight saving transition weeks are confirmed correct - [ ] A reschedule is confirmed to leave one calendar entry - [ ] An availability failure is confirmed to show no slots ## Launch constraint Do not market omitted Calday 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. Links, rules, holds and bookings. Back this up; it is the lock. DATABASE_PATH=./data/booking.db # Required. The registered application. Public half of the OAuth pair. CALENDAR_CLIENT_ID=123456789-abc.apps.example.com # Required · secret. Never in the repository, never in a client bundle. CALENDAR_CLIENT_SECRET=secret-...redacted # Required. Used for the callback and in every link you email. Must match the registered callback. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Transactional email. Confirmations, cancellations and reschedules. EMAIL_API_KEY=re_...redacted # Required. How long cached busy intervals are fresh. Past this they are stale, not current. FREEBUSY_TTL_SECONDS=120 # Required. How long the form holds a slot. Long enough to type, short enough not to block. HOLD_MINUTES=10 # Required. After this the name, email and note are removed and the booking stays. INVITEE_RETENTION_DAYS=365
$ choose a build depth, inspect the files, then open the complete pack in your agent
Calday: 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 6 free alternatives to Calday →· no votes, no pay-to-list · just what's real
Calday pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0 | $0 | 1 service, up to 100 clients, 1 integration and 1 booking page |
| pro | $12 | $9.60 | Unlimited services/clients; unlimited Google, Outlook and iCloud calendars plus Zoom/Meet; payments, add-ons and reminders |
free tier1 service, 100 clients, 1 integration and 1 booking page
billingmonthly + annual (-20%); 7-day Pro trial, no card; month-to-month cancellation
hidden costsStripe's normal payment-processing fees apply, though Calday takes no transaction cut. Existing customers are grandfathered if published prices change.
verified 2026-08-14 · source ↗
Vibecode Calday
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Calday replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Calday cost?
Calday'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 Calday?
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 Calday?
Yes: Easy!Appointments (A proper provider-and-service booking system, provided you will also become its PHP and MySQL caretaker.) Appointy Free (One staffer gets reminders, classes and Square checkout for 100 bookings a month; growth wakes the paywall.) Setmore Free (Four staff, payments, classes and 200 monthly bookings; SMS, recurring appointments and two-way sync cost extra.) All 6 curated free alternatives are at vibecodeit.com/calday/alternatives. The prompt is for when you want it exactly your way.