The visible scheduling + routing loop is buildable, but a credible replacement needs more than the first screen. Zeeg earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Zeeg. 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 ===== # Zeeg · indie build A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with PostgreSQL | slot reservation needs real transactions, and routing rules are relational | | Calendar | One provider read as busy intervals through incremental sync | the sync token, not re-reading every event, is how you stay current | | Time | Instants in the database, rules in the host's zone, display in the visitor's zone | the two people are in different places and the clocks change twice a year | | Routing | Rules as ordered data with a default route and a test harness | a routing form in code cannot be read by the person who owns it | | Retention | Visitor data kept for a stated period then removed on a schedule | collected for one appointment, not for a mailing list | ## 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 server, the sync loop and the reminder queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Reservation correctness depends on real transactions and row contention. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar provider account with API credentials** · free - Why: Incremental sync, token invalidation and scope choice are provider specifics that only appear against a real account, and a recurring series with a cancelled instance is where availability bugs show. - Get it: Create OAuth credentials in the provider's console, request the narrowest scope that reads busy time and writes your own events, and read the sync guide before writing the loop. - Verify: An incremental sync picks up an event added by hand - [ ] **An email provider for confirmations and reminders** · free tier or pennies - Why: A reminder that never arrives is a missed appointment, and delivery has to be retried and recorded. - Get it: Any provider with an API key and a verified sender. - Verify: A test reminder arrives with the recipient's zone named - [ ] **Your retention period and your host timezone rule** · free - Why: How long visitor data lives and whose zone the rules are written in are policy decisions that belong written before the first booking. - Get it: Write down the period after an appointment at which visitor data is removed, and whether the host's zone is fixed or follows the calendar. - Verify: Both are in the configuration and stated on the confirmation - [ ] **A real routing form with at least five rules** · free - Why: Overlapping rules and the default route only get tested against a form someone actually needs, with answers that fall between the cracks. - Get it: Write out the questions you ask new clients and where each answer should send them, including the awkward combinations. - Verify: The harness runs ten sample answer sets through the form ## 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 scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. - 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 Zeeg, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Zeeg 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 with routing forms that reads one calendar and never shows a visitor what is in it to replace Zeeg. 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 A booking page is a window into your calendar, and the whole design question is how small that window can be. A visitor needs to know that a slot is available. They do not need to know that you are busy at three because of a hospital appointment, and neither does the database behind the page. So the calendar is read as busy intervals and nothing else. No titles, descriptions, attendees or locations are stored, asserted in the suite, and the page reveals availability only for the slots it offers rather than exposing the full busy map. The permission requested is the narrowest that lets the tool read busy time and write its own events. The other side of the window is the visitor. Their name, email and answers are personal data collected for one appointment, kept for a stated period after it and then removed, and the confirmation states both. Data minimisation is the feature here, not a checkbox. ### Stack (fixed, do not substitute) - Runtime: TypeScript with PostgreSQL · slot reservation needs real transactions, and routing rules are relational - Calendar: One provider read as busy intervals through incremental sync · the sync token, not re-reading every event, is how you stay current - Time: Instants in the database, rules in the host's zone, display in the visitor's zone · the two people are in different places and the clocks change twice a year - Routing: Rules as ordered data with a default route and a test harness · a routing form in code cannot be read by the person who owns it - Retention: Visitor data kept for a stated period then removed on a schedule · collected for one appointment, not for a mailing list ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_json, sync_token, last_synced_at) - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) · intervals only, never a title - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, availability_rules_json, timezone, questions_json) - routing_forms(id, slug, version, questions_json, rules_json, default_route_json) · rules are ordered and first match wins - bookings(id, event_type_id, starts_at, ends_at, visitor_name, visitor_email, visitor_timezone, answers_json, routed_by, state, external_event_id, purge_at) - holds(id, event_type_id, starts_at, expires_at, session_id) · counted against capacity while alive - The busy table has no column for what an event is, so there is nothing to leak; the tool can only ever know that a period is taken. ### Phase 1 · Busy intervals, not events Build: Connect one calendar with the narrowest scope that reads events and writes the tool's own. Sync incrementally with the provider's sync token, storing start, end and the external id only. On a 410 response clear the stored intervals and perform a full sync, because that status means the token is invalid. Use identical query parameters on every request. Treat a stale sync as narrowing availability. Trap: The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. Done when: A synced calendar produces busy intervals with no title, description, attendee or location anywhere in the database or the logs, asserted in the suite. An incremental sync picks up a newly added event without re-reading the calendar. A 410 response triggers a full resync that leaves no duplicate intervals. A calendar not synced within the stated window narrows availability rather than offering slots optimistically. Do not build yet: No availability rules, booking, routing or notifications. ### Phase 2 · Slots that are right in two timezones Build: Availability rules in the host's timezone, expanded per day into instants with buffers and existing bookings applied. Detect the visitor's zone, let them change it, and show every slot in it. Test the days when clocks change in either zone. Expose only the offered slots, never the busy map. Trap: Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. Done when: Slots offered on the clock change days in the host's zone and in the visitor's zone are correct, tested against hand-computed expectations. A visitor in another zone sees the same instant as the host at a different wall time, with both shown. The page reveals only offered slots and never the underlying busy intervals, verified by inspecting every response. Buffers and existing bookings remove slots correctly. Do not build yet: No booking, routing or notifications yet. ### Phase 3 · Booking once and writing it back Build: Hold a slot for a short expiring period while the visitor fills the form. Create the booking inside a transaction that reserves the slot, so concurrent attempts serialise. Write the event to the calendar and store its id; never modify an event the tool did not create. Confirmation and cancellation links carry unguessable tokens and show both zones. Trap: Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. Done when: Twenty concurrent booking attempts for one slot produce one booking, asserted with real concurrency. The booking's event appears in the calendar identified by its stored id, and a test that attempts to modify a foreign event fails. Holds expire without action and a hold that expired mid-form produces a clear message. Cancellation through the tokenised link frees the slot and removes the event. Do not build yet: No routing forms, reminders or retention yet. ### Phase 4 · Routing forms as rules you can read Build: A routing form with typed questions and an ordered list of rules, each a condition over answers and a destination: an event type, a message, or an external link. First match wins and a default route always exists. A harness runs sample answers through the rules and shows which fired. Store the answers and the rule that routed with the booking. Trap: Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. Done when: Sample answers produce the same destination every time and the harness shows the rule that fired. A form with no matching rule uses the default route rather than failing. Editing a rule creates a new version and existing bookings keep the version that routed them. Answers travel to the booking and appear in the confirmation the host sees. Do not build yet: No reminders, retention or export yet. ### Phase 5 · Reminders, retention and running it in public Build: Reminders through a queue with retries, showing the appointment in the recipient's zone with the zone named, and a calendar attachment. Remove visitor personal data a stated period after the appointment on a schedule, keeping only an anonymised count. Rate limit the public page and the hold endpoint. Export bookings and rules. Pause the page when the calendar connection is revoked rather than offering slots blindly. Trap: Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. Done when: Reminders show the time in the recipient's zone with the zone named, and delivery is retried and recorded. Visitor personal data is removed after the retention period, exercised in the suite, and the confirmation states the period. A revoked calendar connection pauses the public page within one sync interval. The hold endpoint is rate limited so a script cannot hold every slot. Do not build yet: Nothing after this. ### Out of scope (and why) - Team scheduling, round robin and collective availability, which multiply every timezone and privacy question here and are the paid product's real market. - Reading event details for any purpose, since the tool needs to know when you are busy and never why. - Payments at booking, which add a webhook-confirmed flow this page does not need and would hold visitor payment data longer than the appointment. - Two-way sync of events the tool did not create, which is where duplication bugs and accidental edits live. ### README must contain - That the calendar is read as busy intervals only and no title, description, attendee or location is ever stored, asserted in the suite - That a 410 from the calendar provider means the sync token is invalid and triggers a full resync rather than a retry - That slots are expanded from rules in the host's zone with a timezone library and tested on the clock change days - That the slot is reserved inside the booking transaction, and the tool only ever touches events it created - That routing rules are ordered data with a default route, and the rule that routed each booking is recorded - That visitor personal data is removed after a stated period, and the confirmation says so ===== AGENTS.md ===== # Agent instructions · Zeeg indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with PostgreSQL, One provider read as busy intervals through incremental sync, Instants in the database, rules in the host's zone, display in the visitor's zone, Rules as ordered data with a default route and a test harness, Visitor data kept for a stated period then removed on a schedule. 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 - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ===== BUILD_PLAN.md ===== # Build plan · Zeeg A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Busy intervals, not events The calendar is read as taken time with nothing else stored, and it stays current. ### Steps 1. Store intervals, never event content Asserted in the suite, including log output. Files: `src/calendar/sync.ts`, `tests/no-event-content.test.ts` 2. Sync incrementally and handle 410 with a full resync Identical query parameters every time, and no duplicates after the resync. Files: `src/calendar/tokens.ts`, `tests/resync.test.ts` 3. Narrow availability on a stale sync Fail closed. A double booking is worse than a missing slot. Files: `src/availability/stale.ts` ### Done when - [ ] No event content is stored anywhere - [ ] New events arrive through incremental sync - [ ] A 410 causes a duplicate-free full resync - [ ] A stale sync narrows availability ### Watch out - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. ## Phase 2 · Slots that are right in two timezones Every offered slot is free for the host and displayed correctly for the visitor, including across a clock change. ### Steps 1. Expand rules in the host's zone into instants With a timezone library, not arithmetic on local midnight. Files: `src/availability/expand.ts`, `src/availability/expand.test.ts` 2. Test the clock change days in both zones Twice a year per zone is where scheduling tools are wrong. Files: `tests/dst.test.ts` 3. Reveal only offered slots Never the busy map, verified on every response. Files: `src/public/slots.ts`, `tests/no-busy-leak.test.ts` ### Done when - [ ] Clock change days produce correct slots - [ ] Visitor and host wall times agree on the instant - [ ] Responses never include the busy map - [ ] Buffers and bookings remove slots ### Watch out - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. ## Phase 3 · Booking once and writing it back Two visitors cannot take one slot, and the booking appears in the calendar without touching anything else. ### Steps 1. Reserve inside the transaction Check-then-insert double-books when a link is popular. Files: `src/booking/create.ts`, `tests/concurrency.test.ts` 2. Own calendar events by stored id Never touch a foreign event, tested by trying. Files: `src/calendar/write.ts`, `tests/foreign-events.test.ts` 3. Tokenise confirmation and cancellation links Unguessable, single purpose, and showing both zones. Files: `src/booking/links.ts` ### Done when - [ ] Twenty concurrent attempts yield one booking - [ ] Calendar events are owned by stored id - [ ] Foreign events cannot be modified - [ ] Cancellation frees the slot and removes the event ### Watch out - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. ## Phase 4 · Routing forms as rules you can read A visitor answers a few questions and lands on the right appointment for a reason the owner can see. ### Steps 1. Evaluate ordered rules, first match wins With a required default route. Files: `src/routing/evaluate.ts`, `src/routing/evaluate.test.ts` 2. Build the harness Sample answers in, destination and fired rule out. Files: `src/routing/harness.ts` 3. Version rules and record which one routed So a client's path is explicable after an edit. Files: `src/routing/versions.ts` ### Done when - [ ] Routing is deterministic with the fired rule shown - [ ] A default route always applies - [ ] Rule edits are versioned - [ ] Answers and the routing rule reach the booking ### Watch out - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ## Not in this build - Team scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. ## After v1, if you want it - Rescheduling links that carry the routing answers forward - Round robin across two hosts, once single-host privacy is proven - A follow-up message after the appointment, sent within the retention period ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Busy intervals, bookings and visitor answers. DATABASE_URL=postgres://localhost:5432/booking # Required · secret. Reads busy time and writes only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required · secret. Paired with the client id. Never logged. CALENDAR_CLIENT_SECRET=client-secret-...redacted # Required. Present as a constant. The tool knows when you are busy and never why. STORE_EVENT_CONTENT=never # Required. Availability rules are written in this zone and expanded with a timezone library. HOST_TIMEZONE=Europe/Berlin # Required. Long enough to fill the form, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required. Visitor data is removed after this. Stated on the confirmation. RETENTION_DAYS_AFTER_APPOINTMENT=30
You are building a lean indie version of Zeeg. 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 ===== # Zeeg · indie build A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with PostgreSQL | slot reservation needs real transactions, and routing rules are relational | | Calendar | One provider read as busy intervals through incremental sync | the sync token, not re-reading every event, is how you stay current | | Time | Instants in the database, rules in the host's zone, display in the visitor's zone | the two people are in different places and the clocks change twice a year | | Routing | Rules as ordered data with a default route and a test harness | a routing form in code cannot be read by the person who owns it | | Retention | Visitor data kept for a stated period then removed on a schedule | collected for one appointment, not for a mailing list | ## 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 server, the sync loop and the reminder queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Reservation correctness depends on real transactions and row contention. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar provider account with API credentials** · free - Why: Incremental sync, token invalidation and scope choice are provider specifics that only appear against a real account, and a recurring series with a cancelled instance is where availability bugs show. - Get it: Create OAuth credentials in the provider's console, request the narrowest scope that reads busy time and writes your own events, and read the sync guide before writing the loop. - Verify: An incremental sync picks up an event added by hand - [ ] **An email provider for confirmations and reminders** · free tier or pennies - Why: A reminder that never arrives is a missed appointment, and delivery has to be retried and recorded. - Get it: Any provider with an API key and a verified sender. - Verify: A test reminder arrives with the recipient's zone named - [ ] **Your retention period and your host timezone rule** · free - Why: How long visitor data lives and whose zone the rules are written in are policy decisions that belong written before the first booking. - Get it: Write down the period after an appointment at which visitor data is removed, and whether the host's zone is fixed or follows the calendar. - Verify: Both are in the configuration and stated on the confirmation - [ ] **A real routing form with at least five rules** · free - Why: Overlapping rules and the default route only get tested against a form someone actually needs, with answers that fall between the cracks. - Get it: Write out the questions you ask new clients and where each answer should send them, including the awkward combinations. - Verify: The harness runs ten sample answer sets through the form ## 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 scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. - 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 Zeeg, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Zeeg 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 with routing forms that reads one calendar and never shows a visitor what is in it to replace Zeeg. 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 A booking page is a window into your calendar, and the whole design question is how small that window can be. A visitor needs to know that a slot is available. They do not need to know that you are busy at three because of a hospital appointment, and neither does the database behind the page. So the calendar is read as busy intervals and nothing else. No titles, descriptions, attendees or locations are stored, asserted in the suite, and the page reveals availability only for the slots it offers rather than exposing the full busy map. The permission requested is the narrowest that lets the tool read busy time and write its own events. The other side of the window is the visitor. Their name, email and answers are personal data collected for one appointment, kept for a stated period after it and then removed, and the confirmation states both. Data minimisation is the feature here, not a checkbox. ### Stack (fixed, do not substitute) - Runtime: TypeScript with PostgreSQL · slot reservation needs real transactions, and routing rules are relational - Calendar: One provider read as busy intervals through incremental sync · the sync token, not re-reading every event, is how you stay current - Time: Instants in the database, rules in the host's zone, display in the visitor's zone · the two people are in different places and the clocks change twice a year - Routing: Rules as ordered data with a default route and a test harness · a routing form in code cannot be read by the person who owns it - Retention: Visitor data kept for a stated period then removed on a schedule · collected for one appointment, not for a mailing list ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_json, sync_token, last_synced_at) - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) · intervals only, never a title - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, availability_rules_json, timezone, questions_json) - routing_forms(id, slug, version, questions_json, rules_json, default_route_json) · rules are ordered and first match wins - bookings(id, event_type_id, starts_at, ends_at, visitor_name, visitor_email, visitor_timezone, answers_json, routed_by, state, external_event_id, purge_at) - holds(id, event_type_id, starts_at, expires_at, session_id) · counted against capacity while alive - The busy table has no column for what an event is, so there is nothing to leak; the tool can only ever know that a period is taken. ### Phase 1 · Busy intervals, not events Build: Connect one calendar with the narrowest scope that reads events and writes the tool's own. Sync incrementally with the provider's sync token, storing start, end and the external id only. On a 410 response clear the stored intervals and perform a full sync, because that status means the token is invalid. Use identical query parameters on every request. Treat a stale sync as narrowing availability. Trap: The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. Done when: A synced calendar produces busy intervals with no title, description, attendee or location anywhere in the database or the logs, asserted in the suite. An incremental sync picks up a newly added event without re-reading the calendar. A 410 response triggers a full resync that leaves no duplicate intervals. A calendar not synced within the stated window narrows availability rather than offering slots optimistically. Do not build yet: No availability rules, booking, routing or notifications. ### Phase 2 · Slots that are right in two timezones Build: Availability rules in the host's timezone, expanded per day into instants with buffers and existing bookings applied. Detect the visitor's zone, let them change it, and show every slot in it. Test the days when clocks change in either zone. Expose only the offered slots, never the busy map. Trap: Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. Done when: Slots offered on the clock change days in the host's zone and in the visitor's zone are correct, tested against hand-computed expectations. A visitor in another zone sees the same instant as the host at a different wall time, with both shown. The page reveals only offered slots and never the underlying busy intervals, verified by inspecting every response. Buffers and existing bookings remove slots correctly. Do not build yet: No booking, routing or notifications yet. ### Phase 3 · Booking once and writing it back Build: Hold a slot for a short expiring period while the visitor fills the form. Create the booking inside a transaction that reserves the slot, so concurrent attempts serialise. Write the event to the calendar and store its id; never modify an event the tool did not create. Confirmation and cancellation links carry unguessable tokens and show both zones. Trap: Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. Done when: Twenty concurrent booking attempts for one slot produce one booking, asserted with real concurrency. The booking's event appears in the calendar identified by its stored id, and a test that attempts to modify a foreign event fails. Holds expire without action and a hold that expired mid-form produces a clear message. Cancellation through the tokenised link frees the slot and removes the event. Do not build yet: No routing forms, reminders or retention yet. ### Phase 4 · Routing forms as rules you can read Build: A routing form with typed questions and an ordered list of rules, each a condition over answers and a destination: an event type, a message, or an external link. First match wins and a default route always exists. A harness runs sample answers through the rules and shows which fired. Store the answers and the rule that routed with the booking. Trap: Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. Done when: Sample answers produce the same destination every time and the harness shows the rule that fired. A form with no matching rule uses the default route rather than failing. Editing a rule creates a new version and existing bookings keep the version that routed them. Answers travel to the booking and appear in the confirmation the host sees. Do not build yet: No reminders, retention or export yet. ### Phase 5 · Reminders, retention and running it in public Build: Reminders through a queue with retries, showing the appointment in the recipient's zone with the zone named, and a calendar attachment. Remove visitor personal data a stated period after the appointment on a schedule, keeping only an anonymised count. Rate limit the public page and the hold endpoint. Export bookings and rules. Pause the page when the calendar connection is revoked rather than offering slots blindly. Trap: Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. Done when: Reminders show the time in the recipient's zone with the zone named, and delivery is retried and recorded. Visitor personal data is removed after the retention period, exercised in the suite, and the confirmation states the period. A revoked calendar connection pauses the public page within one sync interval. The hold endpoint is rate limited so a script cannot hold every slot. Do not build yet: Nothing after this. ### Out of scope (and why) - Team scheduling, round robin and collective availability, which multiply every timezone and privacy question here and are the paid product's real market. - Reading event details for any purpose, since the tool needs to know when you are busy and never why. - Payments at booking, which add a webhook-confirmed flow this page does not need and would hold visitor payment data longer than the appointment. - Two-way sync of events the tool did not create, which is where duplication bugs and accidental edits live. ### README must contain - That the calendar is read as busy intervals only and no title, description, attendee or location is ever stored, asserted in the suite - That a 410 from the calendar provider means the sync token is invalid and triggers a full resync rather than a retry - That slots are expanded from rules in the host's zone with a timezone library and tested on the clock change days - That the slot is reserved inside the booking transaction, and the tool only ever touches events it created - That routing rules are ordered data with a default route, and the rule that routed each booking is recorded - That visitor personal data is removed after a stated period, and the confirmation says so ===== AGENTS.md ===== # Agent instructions · Zeeg indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with PostgreSQL, One provider read as busy intervals through incremental sync, Instants in the database, rules in the host's zone, display in the visitor's zone, Rules as ordered data with a default route and a test harness, Visitor data kept for a stated period then removed on a schedule. 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 - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ===== BUILD_PLAN.md ===== # Build plan · Zeeg A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Busy intervals, not events The calendar is read as taken time with nothing else stored, and it stays current. ### Steps 1. Store intervals, never event content Asserted in the suite, including log output. Files: `src/calendar/sync.ts`, `tests/no-event-content.test.ts` 2. Sync incrementally and handle 410 with a full resync Identical query parameters every time, and no duplicates after the resync. Files: `src/calendar/tokens.ts`, `tests/resync.test.ts` 3. Narrow availability on a stale sync Fail closed. A double booking is worse than a missing slot. Files: `src/availability/stale.ts` ### Done when - [ ] No event content is stored anywhere - [ ] New events arrive through incremental sync - [ ] A 410 causes a duplicate-free full resync - [ ] A stale sync narrows availability ### Watch out - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. ## Phase 2 · Slots that are right in two timezones Every offered slot is free for the host and displayed correctly for the visitor, including across a clock change. ### Steps 1. Expand rules in the host's zone into instants With a timezone library, not arithmetic on local midnight. Files: `src/availability/expand.ts`, `src/availability/expand.test.ts` 2. Test the clock change days in both zones Twice a year per zone is where scheduling tools are wrong. Files: `tests/dst.test.ts` 3. Reveal only offered slots Never the busy map, verified on every response. Files: `src/public/slots.ts`, `tests/no-busy-leak.test.ts` ### Done when - [ ] Clock change days produce correct slots - [ ] Visitor and host wall times agree on the instant - [ ] Responses never include the busy map - [ ] Buffers and bookings remove slots ### Watch out - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. ## Phase 3 · Booking once and writing it back Two visitors cannot take one slot, and the booking appears in the calendar without touching anything else. ### Steps 1. Reserve inside the transaction Check-then-insert double-books when a link is popular. Files: `src/booking/create.ts`, `tests/concurrency.test.ts` 2. Own calendar events by stored id Never touch a foreign event, tested by trying. Files: `src/calendar/write.ts`, `tests/foreign-events.test.ts` 3. Tokenise confirmation and cancellation links Unguessable, single purpose, and showing both zones. Files: `src/booking/links.ts` ### Done when - [ ] Twenty concurrent attempts yield one booking - [ ] Calendar events are owned by stored id - [ ] Foreign events cannot be modified - [ ] Cancellation frees the slot and removes the event ### Watch out - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. ## Phase 4 · Routing forms as rules you can read A visitor answers a few questions and lands on the right appointment for a reason the owner can see. ### Steps 1. Evaluate ordered rules, first match wins With a required default route. Files: `src/routing/evaluate.ts`, `src/routing/evaluate.test.ts` 2. Build the harness Sample answers in, destination and fired rule out. Files: `src/routing/harness.ts` 3. Version rules and record which one routed So a client's path is explicable after an edit. Files: `src/routing/versions.ts` ### Done when - [ ] Routing is deterministic with the fired rule shown - [ ] A default route always applies - [ ] Rule edits are versioned - [ ] Answers and the routing rule reach the booking ### Watch out - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ## Not in this build - Team scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. ## After v1, if you want it - Rescheduling links that carry the routing answers forward - Round robin across two hosts, once single-host privacy is proven - A follow-up message after the appointment, sent within the retention period ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Busy intervals, bookings and visitor answers. DATABASE_URL=postgres://localhost:5432/booking # Required · secret. Reads busy time and writes only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required · secret. Paired with the client id. Never logged. CALENDAR_CLIENT_SECRET=client-secret-...redacted # Required. Present as a constant. The tool knows when you are busy and never why. STORE_EVENT_CONTENT=never # Required. Availability rules are written in this zone and expanded with a timezone library. HOST_TIMEZONE=Europe/Berlin # Required. Long enough to fill the form, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required. Visitor data is removed after this. Stated on the confirmation. RETENTION_DAYS_AFTER_APPOINTMENT=30
You are building a production product version of Zeeg. 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 ===== # Zeeg · product brief ## Problem The visible scheduling + routing loop is buildable, but a credible replacement needs more than the first screen. Zeeg earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that knows when you are busy and never why, offers slots that are right on both sides of a clock change, routes a visitor by rules the owner can read, and forgets the visitor when the appointment is done. ## 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 scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. - 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 content is confirmed stored anywhere - A 410 is confirmed to trigger a duplicate-free full resync - Clock change days are confirmed to produce correct slots - Twenty concurrent attempts are confirmed to yield one booking - Visitor data is confirmed removed after the retention period ===== BRIEF.md ===== # Build brief · Zeeg 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 with routing forms that reads one calendar and never shows a visitor what is in it to replace Zeeg. 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 A booking page is a window into your calendar, and the whole design question is how small that window can be. A visitor needs to know that a slot is available. They do not need to know that you are busy at three because of a hospital appointment, and neither does the database behind the page. So the calendar is read as busy intervals and nothing else. No titles, descriptions, attendees or locations are stored, asserted in the suite, and the page reveals availability only for the slots it offers rather than exposing the full busy map. The permission requested is the narrowest that lets the tool read busy time and write its own events. The other side of the window is the visitor. Their name, email and answers are personal data collected for one appointment, kept for a stated period after it and then removed, and the confirmation states both. Data minimisation is the feature here, not a checkbox. ### Stack (fixed, do not substitute) - Runtime: TypeScript with PostgreSQL · slot reservation needs real transactions, and routing rules are relational - Calendar: One provider read as busy intervals through incremental sync · the sync token, not re-reading every event, is how you stay current - Time: Instants in the database, rules in the host's zone, display in the visitor's zone · the two people are in different places and the clocks change twice a year - Routing: Rules as ordered data with a default route and a test harness · a routing form in code cannot be read by the person who owns it - Retention: Visitor data kept for a stated period then removed on a schedule · collected for one appointment, not for a mailing list ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_json, sync_token, last_synced_at) - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) · intervals only, never a title - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, availability_rules_json, timezone, questions_json) - routing_forms(id, slug, version, questions_json, rules_json, default_route_json) · rules are ordered and first match wins - bookings(id, event_type_id, starts_at, ends_at, visitor_name, visitor_email, visitor_timezone, answers_json, routed_by, state, external_event_id, purge_at) - holds(id, event_type_id, starts_at, expires_at, session_id) · counted against capacity while alive - The busy table has no column for what an event is, so there is nothing to leak; the tool can only ever know that a period is taken. ### Phase 1 · Busy intervals, not events Build: Connect one calendar with the narrowest scope that reads events and writes the tool's own. Sync incrementally with the provider's sync token, storing start, end and the external id only. On a 410 response clear the stored intervals and perform a full sync, because that status means the token is invalid. Use identical query parameters on every request. Treat a stale sync as narrowing availability. Trap: The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. Done when: A synced calendar produces busy intervals with no title, description, attendee or location anywhere in the database or the logs, asserted in the suite. An incremental sync picks up a newly added event without re-reading the calendar. A 410 response triggers a full resync that leaves no duplicate intervals. A calendar not synced within the stated window narrows availability rather than offering slots optimistically. Do not build yet: No availability rules, booking, routing or notifications. ### Phase 2 · Slots that are right in two timezones Build: Availability rules in the host's timezone, expanded per day into instants with buffers and existing bookings applied. Detect the visitor's zone, let them change it, and show every slot in it. Test the days when clocks change in either zone. Expose only the offered slots, never the busy map. Trap: Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. Done when: Slots offered on the clock change days in the host's zone and in the visitor's zone are correct, tested against hand-computed expectations. A visitor in another zone sees the same instant as the host at a different wall time, with both shown. The page reveals only offered slots and never the underlying busy intervals, verified by inspecting every response. Buffers and existing bookings remove slots correctly. Do not build yet: No booking, routing or notifications yet. ### Phase 3 · Booking once and writing it back Build: Hold a slot for a short expiring period while the visitor fills the form. Create the booking inside a transaction that reserves the slot, so concurrent attempts serialise. Write the event to the calendar and store its id; never modify an event the tool did not create. Confirmation and cancellation links carry unguessable tokens and show both zones. Trap: Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. Done when: Twenty concurrent booking attempts for one slot produce one booking, asserted with real concurrency. The booking's event appears in the calendar identified by its stored id, and a test that attempts to modify a foreign event fails. Holds expire without action and a hold that expired mid-form produces a clear message. Cancellation through the tokenised link frees the slot and removes the event. Do not build yet: No routing forms, reminders or retention yet. ### Phase 4 · Routing forms as rules you can read Build: A routing form with typed questions and an ordered list of rules, each a condition over answers and a destination: an event type, a message, or an external link. First match wins and a default route always exists. A harness runs sample answers through the rules and shows which fired. Store the answers and the rule that routed with the booking. Trap: Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. Done when: Sample answers produce the same destination every time and the harness shows the rule that fired. A form with no matching rule uses the default route rather than failing. Editing a rule creates a new version and existing bookings keep the version that routed them. Answers travel to the booking and appear in the confirmation the host sees. Do not build yet: No reminders, retention or export yet. ### Phase 5 · Reminders, retention and running it in public Build: Reminders through a queue with retries, showing the appointment in the recipient's zone with the zone named, and a calendar attachment. Remove visitor personal data a stated period after the appointment on a schedule, keeping only an anonymised count. Rate limit the public page and the hold endpoint. Export bookings and rules. Pause the page when the calendar connection is revoked rather than offering slots blindly. Trap: Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. Done when: Reminders show the time in the recipient's zone with the zone named, and delivery is retried and recorded. Visitor personal data is removed after the retention period, exercised in the suite, and the confirmation states the period. A revoked calendar connection pauses the public page within one sync interval. The hold endpoint is rate limited so a script cannot hold every slot. Do not build yet: Nothing after this. ### Out of scope (and why) - Team scheduling, round robin and collective availability, which multiply every timezone and privacy question here and are the paid product's real market. - Reading event details for any purpose, since the tool needs to know when you are busy and never why. - Payments at booking, which add a webhook-confirmed flow this page does not need and would hold visitor payment data longer than the appointment. - Two-way sync of events the tool did not create, which is where duplication bugs and accidental edits live. ### README must contain - That the calendar is read as busy intervals only and no title, description, attendee or location is ever stored, asserted in the suite - That a 410 from the calendar provider means the sync token is invalid and triggers a full resync rather than a retry - That slots are expanded from rules in the host's zone with a timezone library and tested on the clock change days - That the slot is reserved inside the booking transaction, and the tool only ever touches events it created - That routing rules are ordered data with a default route, and the rule that routed each booking is recorded - That visitor personal data is removed after a stated period, and the confirmation says so ===== ARCHITECTURE.md ===== # Architecture · Zeeg ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with PostgreSQL | slot reservation needs real transactions, and routing rules are relational | | Calendar | One provider read as busy intervals through incremental sync | the sync token, not re-reading every event, is how you stay current | | Time | Instants in the database, rules in the host's zone, display in the visitor's zone | the two people are in different places and the clocks change twice a year | | Routing | Rules as ordered data with a default route and a test harness | a routing form in code cannot be read by the person who owns it | | Retention | Visitor data kept for a stated period then removed on a schedule | collected for one appointment, not for a mailing list | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Calendar | Incremental sync of busy intervals with 410 handling. | Any provider, provided no event content is ever stored. | | Availability | Rule expansion in the host's zone. | Any implementation, provided the clock change days are tested. | | Reservation | Slot capture inside the booking transaction. | Not swappable for check-then-insert, which double-books. | | Routing | Ordered rules with a default and a harness. | Any format, provided the fired rule is recorded on the booking. | | Retention | Scheduled removal of visitor data. | Any scheduler, provided removal is exercised in the suite. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Busy intervals, bookings and visitor answers. - `CALENDAR_CLIENT_ID` · required, secret · Reads busy time and writes only the tool's own events. - `CALENDAR_CLIENT_SECRET` · required, secret · Paired with the client id. Never logged. - `STORE_EVENT_CONTENT` · required · Present as a constant. The tool knows when you are busy and never why. - `HOST_TIMEZONE` · required · Availability rules are written in this zone and expanded with a timezone library. - `HOLD_MINUTES` · required · Long enough to fill the form, short enough not to block a popular slot. - `SYNC_STALE_MINUTES` · required · Beyond this availability narrows rather than being offered optimistically. - `RETENTION_DAYS_AFTER_APPOINTMENT` · required · Visitor data is removed after this. Stated on the confirmation. ## 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 · Zeeg product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with PostgreSQL, One provider read as busy intervals through incremental sync, Instants in the database, rules in the host's zone, display in the visitor's zone, Rules as ordered data with a default route and a test harness, Visitor data kept for a stated period then removed on a schedule. - 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 - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. - Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. ===== MILESTONES.md ===== # Delivery milestones · Zeeg Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Busy intervals, not events The calendar is read as taken time with nothing else stored, and it stays current. ### Steps 1. Store intervals, never event content Asserted in the suite, including log output. Files: `src/calendar/sync.ts`, `tests/no-event-content.test.ts` 2. Sync incrementally and handle 410 with a full resync Identical query parameters every time, and no duplicates after the resync. Files: `src/calendar/tokens.ts`, `tests/resync.test.ts` 3. Narrow availability on a stale sync Fail closed. A double booking is worse than a missing slot. Files: `src/availability/stale.ts` ### Done when - [ ] No event content is stored anywhere - [ ] New events arrive through incremental sync - [ ] A 410 causes a duplicate-free full resync - [ ] A stale sync narrows availability ### Watch out - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. ## M2 · Slots that are right in two timezones Every offered slot is free for the host and displayed correctly for the visitor, including across a clock change. ### Steps 1. Expand rules in the host's zone into instants With a timezone library, not arithmetic on local midnight. Files: `src/availability/expand.ts`, `src/availability/expand.test.ts` 2. Test the clock change days in both zones Twice a year per zone is where scheduling tools are wrong. Files: `tests/dst.test.ts` 3. Reveal only offered slots Never the busy map, verified on every response. Files: `src/public/slots.ts`, `tests/no-busy-leak.test.ts` ### Done when - [ ] Clock change days produce correct slots - [ ] Visitor and host wall times agree on the instant - [ ] Responses never include the busy map - [ ] Buffers and bookings remove slots ### Watch out - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. ## M3 · Booking once and writing it back Two visitors cannot take one slot, and the booking appears in the calendar without touching anything else. ### Steps 1. Reserve inside the transaction Check-then-insert double-books when a link is popular. Files: `src/booking/create.ts`, `tests/concurrency.test.ts` 2. Own calendar events by stored id Never touch a foreign event, tested by trying. Files: `src/calendar/write.ts`, `tests/foreign-events.test.ts` 3. Tokenise confirmation and cancellation links Unguessable, single purpose, and showing both zones. Files: `src/booking/links.ts` ### Done when - [ ] Twenty concurrent attempts yield one booking - [ ] Calendar events are owned by stored id - [ ] Foreign events cannot be modified - [ ] Cancellation frees the slot and removes the event ### Watch out - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. ## M4 · Routing forms as rules you can read A visitor answers a few questions and lands on the right appointment for a reason the owner can see. ### Steps 1. Evaluate ordered rules, first match wins With a required default route. Files: `src/routing/evaluate.ts`, `src/routing/evaluate.test.ts` 2. Build the harness Sample answers in, destination and fired rule out. Files: `src/routing/harness.ts` 3. Version rules and record which one routed So a client's path is explicable after an edit. Files: `src/routing/versions.ts` ### Done when - [ ] Routing is deterministic with the fired rule shown - [ ] A default route always applies - [ ] Rule edits are versioned - [ ] Answers and the routing rule reach the booking ### Watch out - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ## M5 · Reminders, retention and running it in public (production only) Both parties are reminded in their own zone, visitor data expires on schedule, and the public page survives the internet. ### Steps 1. Remind in the recipient's zone with retries Named zone, calendar attachment, recorded delivery. Files: `src/notify/reminders.ts` 2. Run retention on a schedule and test it Collected for one appointment, removed after it. Files: `src/retention.ts`, `tests/retention.test.ts` 3. Pause on a revoked connection and rate limit holds A page that cannot see the calendar must stop offering slots. Files: `src/calendar/health.ts`, `src/public/ratelimit.ts` ### Done when - [ ] Reminders name the recipient's zone and retry - [ ] Visitor data is removed after the period - [ ] A revoked connection pauses the page - [ ] Holds are rate limited ### Watch out - Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. ===== OPERATIONS.md ===== # Operations · Zeeg ## Backup Bookings are commitments to people who will turn up. Nightly backups with a tested restore, and note that visitor answers may include health or legal details depending on the questions asked, which raises the sensitivity of the whole table. ## Restore Restore and confirm bookings and their calendar event ids agree, since a restore that loses the links will duplicate every event on the next write, and confirm the purge dates survived so retention resumes. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar sync age, hold expiry rate and the retention job's last run. A stale sync is the leading indicator of a double booking, and a retention job that stopped is data being kept without consent. ## Incident checklist A visitor learning something about the host's calendar is the failure this build is arranged against, and it cannot happen through the database because the content was never stored. A double booking traces to a reservation outside the transaction or a stale sync. 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 content is confirmed stored anywhere - [ ] A 410 is confirmed to trigger a duplicate-free full resync - [ ] Clock change days are confirmed to produce correct slots - [ ] Twenty concurrent attempts are confirmed to yield one booking - [ ] Visitor data is confirmed removed after the retention period ## Launch constraint Do not market omitted Zeeg capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Busy intervals, bookings and visitor answers. DATABASE_URL=postgres://localhost:5432/booking # Required · secret. Reads busy time and writes only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required · secret. Paired with the client id. Never logged. CALENDAR_CLIENT_SECRET=client-secret-...redacted # Required. Present as a constant. The tool knows when you are busy and never why. STORE_EVENT_CONTENT=never # Required. Availability rules are written in this zone and expanded with a timezone library. HOST_TIMEZONE=Europe/Berlin # Required. Long enough to fill the form, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required. Visitor data is removed after this. Stated on the confirmation. RETENTION_DAYS_AFTER_APPOINTMENT=30
# Zeeg · indie build A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with PostgreSQL | slot reservation needs real transactions, and routing rules are relational | | Calendar | One provider read as busy intervals through incremental sync | the sync token, not re-reading every event, is how you stay current | | Time | Instants in the database, rules in the host's zone, display in the visitor's zone | the two people are in different places and the clocks change twice a year | | Routing | Rules as ordered data with a default route and a test harness | a routing form in code cannot be read by the person who owns it | | Retention | Visitor data kept for a stated period then removed on a schedule | collected for one appointment, not for a mailing list | ## 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 server, the sync loop and the reminder queue. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Reservation correctness depends on real transactions and row contention. - Get it: Docker or your package manager. - Verify: Two concurrent transactions reserving one slot serialise correctly - [ ] **A calendar provider account with API credentials** · free - Why: Incremental sync, token invalidation and scope choice are provider specifics that only appear against a real account, and a recurring series with a cancelled instance is where availability bugs show. - Get it: Create OAuth credentials in the provider's console, request the narrowest scope that reads busy time and writes your own events, and read the sync guide before writing the loop. - Verify: An incremental sync picks up an event added by hand - [ ] **An email provider for confirmations and reminders** · free tier or pennies - Why: A reminder that never arrives is a missed appointment, and delivery has to be retried and recorded. - Get it: Any provider with an API key and a verified sender. - Verify: A test reminder arrives with the recipient's zone named - [ ] **Your retention period and your host timezone rule** · free - Why: How long visitor data lives and whose zone the rules are written in are policy decisions that belong written before the first booking. - Get it: Write down the period after an appointment at which visitor data is removed, and whether the host's zone is fixed or follows the calendar. - Verify: Both are in the configuration and stated on the confirmation - [ ] **A real routing form with at least five rules** · free - Why: Overlapping rules and the default route only get tested against a form someone actually needs, with answers that fall between the cracks. - Get it: Write out the questions you ask new clients and where each answer should send them, including the awkward combinations. - Verify: The harness runs ten sample answer sets through the form ## 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 scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. - 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 Zeeg, and the README should say so rather than pretend.
# Build brief · Zeeg 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 with routing forms that reads one calendar and never shows a visitor what is in it to replace Zeeg. 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 A booking page is a window into your calendar, and the whole design question is how small that window can be. A visitor needs to know that a slot is available. They do not need to know that you are busy at three because of a hospital appointment, and neither does the database behind the page. So the calendar is read as busy intervals and nothing else. No titles, descriptions, attendees or locations are stored, asserted in the suite, and the page reveals availability only for the slots it offers rather than exposing the full busy map. The permission requested is the narrowest that lets the tool read busy time and write its own events. The other side of the window is the visitor. Their name, email and answers are personal data collected for one appointment, kept for a stated period after it and then removed, and the confirmation states both. Data minimisation is the feature here, not a checkbox. ### Stack (fixed, do not substitute) - Runtime: TypeScript with PostgreSQL · slot reservation needs real transactions, and routing rules are relational - Calendar: One provider read as busy intervals through incremental sync · the sync token, not re-reading every event, is how you stay current - Time: Instants in the database, rules in the host's zone, display in the visitor's zone · the two people are in different places and the clocks change twice a year - Routing: Rules as ordered data with a default route and a test harness · a routing form in code cannot be read by the person who owns it - Retention: Visitor data kept for a stated period then removed on a schedule · collected for one appointment, not for a mailing list ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_json, sync_token, last_synced_at) - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) · intervals only, never a title - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, availability_rules_json, timezone, questions_json) - routing_forms(id, slug, version, questions_json, rules_json, default_route_json) · rules are ordered and first match wins - bookings(id, event_type_id, starts_at, ends_at, visitor_name, visitor_email, visitor_timezone, answers_json, routed_by, state, external_event_id, purge_at) - holds(id, event_type_id, starts_at, expires_at, session_id) · counted against capacity while alive - The busy table has no column for what an event is, so there is nothing to leak; the tool can only ever know that a period is taken. ### Phase 1 · Busy intervals, not events Build: Connect one calendar with the narrowest scope that reads events and writes the tool's own. Sync incrementally with the provider's sync token, storing start, end and the external id only. On a 410 response clear the stored intervals and perform a full sync, because that status means the token is invalid. Use identical query parameters on every request. Treat a stale sync as narrowing availability. Trap: The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. Done when: A synced calendar produces busy intervals with no title, description, attendee or location anywhere in the database or the logs, asserted in the suite. An incremental sync picks up a newly added event without re-reading the calendar. A 410 response triggers a full resync that leaves no duplicate intervals. A calendar not synced within the stated window narrows availability rather than offering slots optimistically. Do not build yet: No availability rules, booking, routing or notifications. ### Phase 2 · Slots that are right in two timezones Build: Availability rules in the host's timezone, expanded per day into instants with buffers and existing bookings applied. Detect the visitor's zone, let them change it, and show every slot in it. Test the days when clocks change in either zone. Expose only the offered slots, never the busy map. Trap: Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. Done when: Slots offered on the clock change days in the host's zone and in the visitor's zone are correct, tested against hand-computed expectations. A visitor in another zone sees the same instant as the host at a different wall time, with both shown. The page reveals only offered slots and never the underlying busy intervals, verified by inspecting every response. Buffers and existing bookings remove slots correctly. Do not build yet: No booking, routing or notifications yet. ### Phase 3 · Booking once and writing it back Build: Hold a slot for a short expiring period while the visitor fills the form. Create the booking inside a transaction that reserves the slot, so concurrent attempts serialise. Write the event to the calendar and store its id; never modify an event the tool did not create. Confirmation and cancellation links carry unguessable tokens and show both zones. Trap: Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. Done when: Twenty concurrent booking attempts for one slot produce one booking, asserted with real concurrency. The booking's event appears in the calendar identified by its stored id, and a test that attempts to modify a foreign event fails. Holds expire without action and a hold that expired mid-form produces a clear message. Cancellation through the tokenised link frees the slot and removes the event. Do not build yet: No routing forms, reminders or retention yet. ### Phase 4 · Routing forms as rules you can read Build: A routing form with typed questions and an ordered list of rules, each a condition over answers and a destination: an event type, a message, or an external link. First match wins and a default route always exists. A harness runs sample answers through the rules and shows which fired. Store the answers and the rule that routed with the booking. Trap: Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. Done when: Sample answers produce the same destination every time and the harness shows the rule that fired. A form with no matching rule uses the default route rather than failing. Editing a rule creates a new version and existing bookings keep the version that routed them. Answers travel to the booking and appear in the confirmation the host sees. Do not build yet: No reminders, retention or export yet. ### Phase 5 · Reminders, retention and running it in public Build: Reminders through a queue with retries, showing the appointment in the recipient's zone with the zone named, and a calendar attachment. Remove visitor personal data a stated period after the appointment on a schedule, keeping only an anonymised count. Rate limit the public page and the hold endpoint. Export bookings and rules. Pause the page when the calendar connection is revoked rather than offering slots blindly. Trap: Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. Done when: Reminders show the time in the recipient's zone with the zone named, and delivery is retried and recorded. Visitor personal data is removed after the retention period, exercised in the suite, and the confirmation states the period. A revoked calendar connection pauses the public page within one sync interval. The hold endpoint is rate limited so a script cannot hold every slot. Do not build yet: Nothing after this. ### Out of scope (and why) - Team scheduling, round robin and collective availability, which multiply every timezone and privacy question here and are the paid product's real market. - Reading event details for any purpose, since the tool needs to know when you are busy and never why. - Payments at booking, which add a webhook-confirmed flow this page does not need and would hold visitor payment data longer than the appointment. - Two-way sync of events the tool did not create, which is where duplication bugs and accidental edits live. ### README must contain - That the calendar is read as busy intervals only and no title, description, attendee or location is ever stored, asserted in the suite - That a 410 from the calendar provider means the sync token is invalid and triggers a full resync rather than a retry - That slots are expanded from rules in the host's zone with a timezone library and tested on the clock change days - That the slot is reserved inside the booking transaction, and the tool only ever touches events it created - That routing rules are ordered data with a default route, and the rule that routed each booking is recorded - That visitor personal data is removed after a stated period, and the confirmation says so
# Agent instructions · Zeeg indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with PostgreSQL, One provider read as busy intervals through incremental sync, Instants in the database, rules in the host's zone, display in the visitor's zone, Rules as ordered data with a default route and a test harness, Visitor data kept for a stated period then removed on a schedule. 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 - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking.
# Build plan · Zeeg A scheduling page whose difficulty is keeping two kinds of private information apart: the host's calendar, read as busy intervals and nothing more, and the visitor's details, routed by explicit rules to the right appointment and kept no longer than the appointment needs. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Busy intervals, not events The calendar is read as taken time with nothing else stored, and it stays current. ### Steps 1. Store intervals, never event content Asserted in the suite, including log output. Files: `src/calendar/sync.ts`, `tests/no-event-content.test.ts` 2. Sync incrementally and handle 410 with a full resync Identical query parameters every time, and no duplicates after the resync. Files: `src/calendar/tokens.ts`, `tests/resync.test.ts` 3. Narrow availability on a stale sync Fail closed. A double booking is worse than a missing slot. Files: `src/availability/stale.ts` ### Done when - [ ] No event content is stored anywhere - [ ] New events arrive through incremental sync - [ ] A 410 causes a duplicate-free full resync - [ ] A stale sync narrows availability ### Watch out - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. ## Phase 2 · Slots that are right in two timezones Every offered slot is free for the host and displayed correctly for the visitor, including across a clock change. ### Steps 1. Expand rules in the host's zone into instants With a timezone library, not arithmetic on local midnight. Files: `src/availability/expand.ts`, `src/availability/expand.test.ts` 2. Test the clock change days in both zones Twice a year per zone is where scheduling tools are wrong. Files: `tests/dst.test.ts` 3. Reveal only offered slots Never the busy map, verified on every response. Files: `src/public/slots.ts`, `tests/no-busy-leak.test.ts` ### Done when - [ ] Clock change days produce correct slots - [ ] Visitor and host wall times agree on the instant - [ ] Responses never include the busy map - [ ] Buffers and bookings remove slots ### Watch out - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. ## Phase 3 · Booking once and writing it back Two visitors cannot take one slot, and the booking appears in the calendar without touching anything else. ### Steps 1. Reserve inside the transaction Check-then-insert double-books when a link is popular. Files: `src/booking/create.ts`, `tests/concurrency.test.ts` 2. Own calendar events by stored id Never touch a foreign event, tested by trying. Files: `src/calendar/write.ts`, `tests/foreign-events.test.ts` 3. Tokenise confirmation and cancellation links Unguessable, single purpose, and showing both zones. Files: `src/booking/links.ts` ### Done when - [ ] Twenty concurrent attempts yield one booking - [ ] Calendar events are owned by stored id - [ ] Foreign events cannot be modified - [ ] Cancellation frees the slot and removes the event ### Watch out - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. ## Phase 4 · Routing forms as rules you can read A visitor answers a few questions and lands on the right appointment for a reason the owner can see. ### Steps 1. Evaluate ordered rules, first match wins With a required default route. Files: `src/routing/evaluate.ts`, `src/routing/evaluate.test.ts` 2. Build the harness Sample answers in, destination and fired rule out. Files: `src/routing/harness.ts` 3. Version rules and record which one routed So a client's path is explicable after an edit. Files: `src/routing/versions.ts` ### Done when - [ ] Routing is deterministic with the fired rule shown - [ ] A default route always applies - [ ] Rule edits are versioned - [ ] Answers and the routing rule reach the booking ### Watch out - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ## Not in this build - Team scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. ## After v1, if you want it - Rescheduling links that carry the routing answers forward - Round robin across two hosts, once single-host privacy is proven - A follow-up message after the appointment, sent within the retention period
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Busy intervals, bookings and visitor answers. DATABASE_URL=postgres://localhost:5432/booking # Required · secret. Reads busy time and writes only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required · secret. Paired with the client id. Never logged. CALENDAR_CLIENT_SECRET=client-secret-...redacted # Required. Present as a constant. The tool knows when you are busy and never why. STORE_EVENT_CONTENT=never # Required. Availability rules are written in this zone and expanded with a timezone library. HOST_TIMEZONE=Europe/Berlin # Required. Long enough to fill the form, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required. Visitor data is removed after this. Stated on the confirmation. RETENTION_DAYS_AFTER_APPOINTMENT=30
# Zeeg · product brief ## Problem The visible scheduling + routing loop is buildable, but a credible replacement needs more than the first screen. Zeeg earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that knows when you are busy and never why, offers slots that are right on both sides of a clock change, routes a visitor by rules the owner can read, and forgets the visitor when the appointment is done. ## 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 scheduling and round robin. - Reading event details for any purpose. - Payments at booking. - Two-way sync of foreign events. - 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 content is confirmed stored anywhere - A 410 is confirmed to trigger a duplicate-free full resync - Clock change days are confirmed to produce correct slots - Twenty concurrent attempts are confirmed to yield one booking - Visitor data is confirmed removed after the retention period
# Build brief · Zeeg 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 with routing forms that reads one calendar and never shows a visitor what is in it to replace Zeeg. 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 A booking page is a window into your calendar, and the whole design question is how small that window can be. A visitor needs to know that a slot is available. They do not need to know that you are busy at three because of a hospital appointment, and neither does the database behind the page. So the calendar is read as busy intervals and nothing else. No titles, descriptions, attendees or locations are stored, asserted in the suite, and the page reveals availability only for the slots it offers rather than exposing the full busy map. The permission requested is the narrowest that lets the tool read busy time and write its own events. The other side of the window is the visitor. Their name, email and answers are personal data collected for one appointment, kept for a stated period after it and then removed, and the confirmation states both. Data minimisation is the feature here, not a checkbox. ### Stack (fixed, do not substitute) - Runtime: TypeScript with PostgreSQL · slot reservation needs real transactions, and routing rules are relational - Calendar: One provider read as busy intervals through incremental sync · the sync token, not re-reading every event, is how you stay current - Time: Instants in the database, rules in the host's zone, display in the visitor's zone · the two people are in different places and the clocks change twice a year - Routing: Rules as ordered data with a default route and a test harness · a routing form in code cannot be read by the person who owns it - Retention: Visitor data kept for a stated period then removed on a schedule · collected for one appointment, not for a mailing list ### Data model (create this before Phase 1) - calendars(id, provider, account_ref, scopes_json, sync_token, last_synced_at) - busy(id, calendar_id, starts_at, ends_at, external_event_id, is_ours) · intervals only, never a title - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, availability_rules_json, timezone, questions_json) - routing_forms(id, slug, version, questions_json, rules_json, default_route_json) · rules are ordered and first match wins - bookings(id, event_type_id, starts_at, ends_at, visitor_name, visitor_email, visitor_timezone, answers_json, routed_by, state, external_event_id, purge_at) - holds(id, event_type_id, starts_at, expires_at, session_id) · counted against capacity while alive - The busy table has no column for what an event is, so there is nothing to leak; the tool can only ever know that a period is taken. ### Phase 1 · Busy intervals, not events Build: Connect one calendar with the narrowest scope that reads events and writes the tool's own. Sync incrementally with the provider's sync token, storing start, end and the external id only. On a 410 response clear the stored intervals and perform a full sync, because that status means the token is invalid. Use identical query parameters on every request. Treat a stale sync as narrowing availability. Trap: The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. Done when: A synced calendar produces busy intervals with no title, description, attendee or location anywhere in the database or the logs, asserted in the suite. An incremental sync picks up a newly added event without re-reading the calendar. A 410 response triggers a full resync that leaves no duplicate intervals. A calendar not synced within the stated window narrows availability rather than offering slots optimistically. Do not build yet: No availability rules, booking, routing or notifications. ### Phase 2 · Slots that are right in two timezones Build: Availability rules in the host's timezone, expanded per day into instants with buffers and existing bookings applied. Detect the visitor's zone, let them change it, and show every slot in it. Test the days when clocks change in either zone. Expose only the offered slots, never the busy map. Trap: Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. Done when: Slots offered on the clock change days in the host's zone and in the visitor's zone are correct, tested against hand-computed expectations. A visitor in another zone sees the same instant as the host at a different wall time, with both shown. The page reveals only offered slots and never the underlying busy intervals, verified by inspecting every response. Buffers and existing bookings remove slots correctly. Do not build yet: No booking, routing or notifications yet. ### Phase 3 · Booking once and writing it back Build: Hold a slot for a short expiring period while the visitor fills the form. Create the booking inside a transaction that reserves the slot, so concurrent attempts serialise. Write the event to the calendar and store its id; never modify an event the tool did not create. Confirmation and cancellation links carry unguessable tokens and show both zones. Trap: Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. Done when: Twenty concurrent booking attempts for one slot produce one booking, asserted with real concurrency. The booking's event appears in the calendar identified by its stored id, and a test that attempts to modify a foreign event fails. Holds expire without action and a hold that expired mid-form produces a clear message. Cancellation through the tokenised link frees the slot and removes the event. Do not build yet: No routing forms, reminders or retention yet. ### Phase 4 · Routing forms as rules you can read Build: A routing form with typed questions and an ordered list of rules, each a condition over answers and a destination: an event type, a message, or an external link. First match wins and a default route always exists. A harness runs sample answers through the rules and shows which fired. Store the answers and the rule that routed with the booking. Trap: Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. Done when: Sample answers produce the same destination every time and the harness shows the rule that fired. A form with no matching rule uses the default route rather than failing. Editing a rule creates a new version and existing bookings keep the version that routed them. Answers travel to the booking and appear in the confirmation the host sees. Do not build yet: No reminders, retention or export yet. ### Phase 5 · Reminders, retention and running it in public Build: Reminders through a queue with retries, showing the appointment in the recipient's zone with the zone named, and a calendar attachment. Remove visitor personal data a stated period after the appointment on a schedule, keeping only an anonymised count. Rate limit the public page and the hold endpoint. Export bookings and rules. Pause the page when the calendar connection is revoked rather than offering slots blindly. Trap: Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page. Done when: Reminders show the time in the recipient's zone with the zone named, and delivery is retried and recorded. Visitor personal data is removed after the retention period, exercised in the suite, and the confirmation states the period. A revoked calendar connection pauses the public page within one sync interval. The hold endpoint is rate limited so a script cannot hold every slot. Do not build yet: Nothing after this. ### Out of scope (and why) - Team scheduling, round robin and collective availability, which multiply every timezone and privacy question here and are the paid product's real market. - Reading event details for any purpose, since the tool needs to know when you are busy and never why. - Payments at booking, which add a webhook-confirmed flow this page does not need and would hold visitor payment data longer than the appointment. - Two-way sync of events the tool did not create, which is where duplication bugs and accidental edits live. ### README must contain - That the calendar is read as busy intervals only and no title, description, attendee or location is ever stored, asserted in the suite - That a 410 from the calendar provider means the sync token is invalid and triggers a full resync rather than a retry - That slots are expanded from rules in the host's zone with a timezone library and tested on the clock change days - That the slot is reserved inside the booking transaction, and the tool only ever touches events it created - That routing rules are ordered data with a default route, and the rule that routed each booking is recorded - That visitor personal data is removed after a stated period, and the confirmation says so
# Architecture · Zeeg ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with PostgreSQL | slot reservation needs real transactions, and routing rules are relational | | Calendar | One provider read as busy intervals through incremental sync | the sync token, not re-reading every event, is how you stay current | | Time | Instants in the database, rules in the host's zone, display in the visitor's zone | the two people are in different places and the clocks change twice a year | | Routing | Rules as ordered data with a default route and a test harness | a routing form in code cannot be read by the person who owns it | | Retention | Visitor data kept for a stated period then removed on a schedule | collected for one appointment, not for a mailing list | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Calendar | Incremental sync of busy intervals with 410 handling. | Any provider, provided no event content is ever stored. | | Availability | Rule expansion in the host's zone. | Any implementation, provided the clock change days are tested. | | Reservation | Slot capture inside the booking transaction. | Not swappable for check-then-insert, which double-books. | | Routing | Ordered rules with a default and a harness. | Any format, provided the fired rule is recorded on the booking. | | Retention | Scheduled removal of visitor data. | Any scheduler, provided removal is exercised in the suite. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · Busy intervals, bookings and visitor answers. - `CALENDAR_CLIENT_ID` · required, secret · Reads busy time and writes only the tool's own events. - `CALENDAR_CLIENT_SECRET` · required, secret · Paired with the client id. Never logged. - `STORE_EVENT_CONTENT` · required · Present as a constant. The tool knows when you are busy and never why. - `HOST_TIMEZONE` · required · Availability rules are written in this zone and expanded with a timezone library. - `HOLD_MINUTES` · required · Long enough to fill the form, short enough not to block a popular slot. - `SYNC_STALE_MINUTES` · required · Beyond this availability narrows rather than being offered optimistically. - `RETENTION_DAYS_AFTER_APPOINTMENT` · required · Visitor data is removed after this. Stated on the confirmation. ## 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 · Zeeg product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with PostgreSQL, One provider read as busy intervals through incremental sync, Instants in the database, rules in the host's zone, display in the visitor's zone, Rules as ordered data with a default route and a test harness, Visitor data kept for a stated period then removed on a schedule. - 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 - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. - Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page.
# Delivery milestones · Zeeg Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Busy intervals, not events The calendar is read as taken time with nothing else stored, and it stays current. ### Steps 1. Store intervals, never event content Asserted in the suite, including log output. Files: `src/calendar/sync.ts`, `tests/no-event-content.test.ts` 2. Sync incrementally and handle 410 with a full resync Identical query parameters every time, and no duplicates after the resync. Files: `src/calendar/tokens.ts`, `tests/resync.test.ts` 3. Narrow availability on a stale sync Fail closed. A double booking is worse than a missing slot. Files: `src/availability/stale.ts` ### Done when - [ ] No event content is stored anywhere - [ ] New events arrive through incremental sync - [ ] A 410 causes a duplicate-free full resync - [ ] A stale sync narrows availability ### Watch out - The provider invalidates sync tokens from time to time and answers with a 410, and a sync loop that treats that as a transient error retries forever while availability drifts from reality; the same guide notes that changing query parameters between requests earns a 400. Handle 410 with a full resync, keep parameters identical, and assert in the suite that no title, description, attendee or location is ever stored. ## M2 · Slots that are right in two timezones Every offered slot is free for the host and displayed correctly for the visitor, including across a clock change. ### Steps 1. Expand rules in the host's zone into instants With a timezone library, not arithmetic on local midnight. Files: `src/availability/expand.ts`, `src/availability/expand.test.ts` 2. Test the clock change days in both zones Twice a year per zone is where scheduling tools are wrong. Files: `tests/dst.test.ts` 3. Reveal only offered slots Never the busy map, verified on every response. Files: `src/public/slots.ts`, `tests/no-busy-leak.test.ts` ### Done when - [ ] Clock change days produce correct slots - [ ] Visitor and host wall times agree on the instant - [ ] Responses never include the busy map - [ ] Buffers and bookings remove slots ### Watch out - Generating slots by adding minutes to a local midnight breaks on the day the clocks change, offering a slot that does not exist or skipping an hour that does, once a year in each zone, which is rare enough to survive testing and common enough to hit real clients. Expand rules with a timezone library, test both transition days in both zones, and store instants. ## M3 · Booking once and writing it back Two visitors cannot take one slot, and the booking appears in the calendar without touching anything else. ### Steps 1. Reserve inside the transaction Check-then-insert double-books when a link is popular. Files: `src/booking/create.ts`, `tests/concurrency.test.ts` 2. Own calendar events by stored id Never touch a foreign event, tested by trying. Files: `src/calendar/write.ts`, `tests/foreign-events.test.ts` 3. Tokenise confirmation and cancellation links Unguessable, single purpose, and showing both zones. Files: `src/booking/links.ts` ### Done when - [ ] Twenty concurrent attempts yield one booking - [ ] Calendar events are owned by stored id - [ ] Foreign events cannot be modified - [ ] Cancellation frees the slot and removes the event ### Watch out - Checking availability and then inserting the booking is a race of a few hundred milliseconds, and it double-books exactly when a link is shared widely. Reserve inside the transaction and let the database serialise it, then write the calendar event after the commit, identified by its stored id. ## M4 · Routing forms as rules you can read A visitor answers a few questions and lands on the right appointment for a reason the owner can see. ### Steps 1. Evaluate ordered rules, first match wins With a required default route. Files: `src/routing/evaluate.ts`, `src/routing/evaluate.test.ts` 2. Build the harness Sample answers in, destination and fired rule out. Files: `src/routing/harness.ts` 3. Version rules and record which one routed So a client's path is explicable after an edit. Files: `src/routing/versions.ts` ### Done when - [ ] Routing is deterministic with the fired rule shown - [ ] A default route always applies - [ ] Rule edits are versioned - [ ] Answers and the routing rule reach the booking ### Watch out - Rules that overlap with no defined order route the same answers differently after an edit, and the owner cannot explain why a client landed on the wrong page. Order the rules explicitly, evaluate first match wins, require a default, and show which rule fired in the harness and on the booking. ## M5 · Reminders, retention and running it in public (production only) Both parties are reminded in their own zone, visitor data expires on schedule, and the public page survives the internet. ### Steps 1. Remind in the recipient's zone with retries Named zone, calendar attachment, recorded delivery. Files: `src/notify/reminders.ts` 2. Run retention on a schedule and test it Collected for one appointment, removed after it. Files: `src/retention.ts`, `tests/retention.test.ts` 3. Pause on a revoked connection and rate limit holds A page that cannot see the calendar must stop offering slots. Files: `src/calendar/health.ts`, `src/public/ratelimit.ts` ### Done when - [ ] Reminders name the recipient's zone and retry - [ ] Visitor data is removed after the period - [ ] A revoked connection pauses the page - [ ] Holds are rate limited ### Watch out - Keeping every visitor's name, email and answers forever turns a booking tool into a contact database nobody consented to, and a revoked calendar token that goes unnoticed leaves a page offering slots against a calendar it can no longer see. Retention runs on a schedule and is tested, and a revoked connection pauses the page.
# Operations · Zeeg ## Backup Bookings are commitments to people who will turn up. Nightly backups with a tested restore, and note that visitor answers may include health or legal details depending on the questions asked, which raises the sensitivity of the whole table. ## Restore Restore and confirm bookings and their calendar event ids agree, since a restore that loses the links will duplicate every event on the next write, and confirm the purge dates survived so retention resumes. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Calendar sync age, hold expiry rate and the retention job's last run. A stale sync is the leading indicator of a double booking, and a retention job that stopped is data being kept without consent. ## Incident checklist A visitor learning something about the host's calendar is the failure this build is arranged against, and it cannot happen through the database because the content was never stored. A double booking traces to a reservation outside the transaction or a stale sync. 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 content is confirmed stored anywhere - [ ] A 410 is confirmed to trigger a duplicate-free full resync - [ ] Clock change days are confirmed to produce correct slots - [ ] Twenty concurrent attempts are confirmed to yield one booking - [ ] Visitor data is confirmed removed after the retention period ## Launch constraint Do not market omitted Zeeg capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Busy intervals, bookings and visitor answers. DATABASE_URL=postgres://localhost:5432/booking # Required · secret. Reads busy time and writes only the tool's own events. CALENDAR_CLIENT_ID=a provider client id # Required · secret. Paired with the client id. Never logged. CALENDAR_CLIENT_SECRET=client-secret-...redacted # Required. Present as a constant. The tool knows when you are busy and never why. STORE_EVENT_CONTENT=never # Required. Availability rules are written in this zone and expanded with a timezone library. HOST_TIMEZONE=Europe/Berlin # Required. Long enough to fill the form, short enough not to block a popular slot. HOLD_MINUTES=10 # Required. Beyond this availability narrows rather than being offered optimistically. SYNC_STALE_MINUTES=30 # Required. Visitor data is removed after this. Stated on the confirmation. RETENTION_DAYS_AFTER_APPOINTMENT=30
$ choose a build depth, inspect the files, then open the complete pack in your agent
Zeeg: 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
Zeeg pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $0/user | $0/user | 2 active scheduling pages; unlimited events. |
| professional | $13.86/user | $11.55/user | Unlimited scheduling pages; advanced reminders and integrations. |
| business | $23.11/user | $18.49/user | Routing forms, team scheduling, and workflows; no single numeric workflow cap published. |
| scale | $46.21/user | $34.66/user | Advanced administration and security; no public numeric seat minimum. |
| enterprise | custom | — | Custom limits, support, and contract. |
free tier2 active scheduling pages; unlimited events.
billingmonthly + annual (up to -25%); Enterprise custom
hidden costsVAT may apply depending on location.
verified 2026-08-14 · source ↗
Vibecode Zeeg
Kinda. The core of Zeeg is buildable in a weekend with the prompt on this page, but there are real gaps: race-free booking under load, team routing and round robin. Read the honest list above before committing.
How much does Zeeg cost?
Zeeg'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 Zeeg?
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 Zeeg?
Yes: Cal.com (Mature open-source scheduling platform and OAuth reference.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.