zcal's solo core is compact: build a personal freeform scheduling tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability.
You are building a lean indie version of zcal. 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 ===== # zcal · indie build A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process, SQLite | a booking page is small and all of its difficulty is in the details | | Calendar read | Google Calendar freeBusy only, never events.list | freeBusy returns busy ranges without titles, so private events stay private | | Time | Instants stored in UTC, zones resolved from the IANA database at display | almost every scheduling bug is a timezone bug | | Booking | A local reservation row with a unique constraint, taken before the calendar is touched | two people clicking the same slot is the race that matters | | Calendar write | events.insert with a client-supplied base32hex id derived from the reservation | a retry after a timeout writes the same event rather than a second one | | Mail | One provider behind one send function, with a calendar invitation attached | the confirmation is as much the product as the page is | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the calendar client in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud OAuth client for the Calendar API** · free - Why: Reading freeBusy and writing events both need an OAuth client, and the consent screen configuration decides how often your refresh token survives. - Get it: Create a project, enable the Calendar API, create an OAuth client of type web application and add your callback URL. - Verify: A consent round trip returns a refresh token and a freeBusy query succeeds - [ ] **A throwaway calendar with real-looking events** · free - Why: The privacy test and the drift test are only meaningful against a calendar containing events whose titles you would mind leaking. - Get it: Create a second calendar on the same account and add a few private events across a clock change weekend. - Verify: freeBusy returns busy ranges for it and no titles - [ ] **A host with a stable HTTPS URL** · a few pounds a month - Why: The OAuth callback and the public booking page both need an address that does not change, and the callback must match the client exactly. - Get it: A small VPS with a domain, or a tunnel with a fixed hostname for development. - Verify: The callback URL registered in the client resolves and completes consent - [ ] **An email provider for confirmations** · free tier is enough for personal use - Why: A booking nobody was told about is not a booking, and the invitation attachment is what puts it in the invitee's calendar. - Get it: Any transactional provider or an SMTP account you already own. - Verify: A confirmation arrives with a valid calendar invitation attached - [ ] **Working hours, buffers, notice and the clock change rule** · free - Why: Each one is asserted by a test, and deciding them after seeing which slots appear means choosing whatever the code already did. - Get it: Write down your hours per weekday in your zone, the buffers, the minimum notice, the horizon, and what happens to a slot in the missing hour. - Verify: All five appear in configuration and are named in the tests ## 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: - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for zcal, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · zcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that offers only the slots you are really free and cannot hand the same one to two people to replace zcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, one process, SQLite · a booking page is small and all of its difficulty is in the details - Calendar read: Google Calendar freeBusy only, never events.list · freeBusy returns busy ranges without titles, so private events stay private - Time: Instants stored in UTC, zones resolved from the IANA database at display · almost every scheduling bug is a timezone bug - Booking: A local reservation row with a unique constraint, taken before the calendar is touched · two people clicking the same slot is the race that matters - Calendar write: events.insert with a client-supplied base32hex id derived from the reservation · a retry after a timeout writes the same event rather than a second one - Mail: One provider behind one send function, with a calendar invitation attached · the confirmation is as much the product as the page is ### Data model (create this before Phase 1) - availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens. ### Phase 1 · Busy, without reading anyone's calendar Build: Connect one Google account and compute availability from the freeBusy endpoint against your working hours, buffers, minimum notice and maximum days ahead. Cache what freeBusy returned with its fetch time. Respect the documented ceiling on how many calendars one query may expand, which is 50 (checked against the current reference), and batch beyond it. Trap: Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. Done when: Availability is computed only from freeBusy, and a test asserts that no field named summary, description or attendees is ever stored or logged. A private event blocks its slot without its title appearing anywhere. A query covering more than 50 calendars is batched rather than silently truncated. Buffers, minimum notice and maximum days ahead all change the offered slots, each covered by a test. Do not build yet: No public page, booking, email or cancellation. ### Phase 2 · Two timezones and the two awkward days Build: A public page that detects the visitor's zone, lets them change it, and renders each slot as a local time in both zones. Compute your working hours from the zone rules for that date. Handle a working day that crosses a clock change in either zone, and store only the instant. Trap: Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. Done when: Slots on both clock change days in your zone resolve by a stated rule and are covered by tests. An invitee in a zone whose transition falls on a different date sees the correct local time throughout, tested with two zones that change on different days. The instant stored and the two local times displayed always agree. Changing the displayed zone never changes which slots are offered. Do not build yet: No booking, calendar write or email yet. ### Phase 3 · One slot, one booking Build: Insert a reservation row in its own transaction, with a unique constraint on event type and slot start, before any calendar call. Then call events.insert with an id derived from the reservation in the base32hex alphabet. On a timeout, settle by fetching the event by that id rather than inserting again. The loser of the race gets a refreshed slot list, not an error page. Trap: Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. Done when: Two concurrent bookings of one slot produce one reservation and one calendar event, tested with parallel requests. A calendar call that times out is settled by fetching the derived id, and a second insert is never issued, tested with a fake that accepts then hangs. The derived id uses only lowercase a to v and digits and is between 5 and 1024 characters, asserted in the suite. The losing booker is shown the updated slot list rather than a failure. Do not build yet: No email, reschedule or cancellation yet. ### Phase 4 · Confirmation, reschedule and cancel Build: Send both parties a confirmation carrying a calendar invitation and both local times. Issue a cancel link and a reschedule link as single-purpose tokens stored hashed with an expiry. Cancelling frees the slot and removes the calendar event. Rescheduling is a cancel and a book inside one transaction, so a failure leaves the original booking intact. Trap: Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. Done when: A cancel link built from a guessed or incremented identifier is refused, tested directly. Tokens are stored hashed and a raw token never appears in the database or in logs, verified with a canary. Cancelling frees the slot within the same request and removes the calendar event. A reschedule whose booking half fails leaves the original reservation and its calendar event untouched, tested by injecting a failure. Do not build yet: No token expiry handling, reconciliation or export yet. ### Phase 5 · When the token dies and the calendar drifts Build: Refresh the access token and treat a refresh failure as a state, not an exception. If the busy cache is older than a stated age and a refresh fails, stop offering slots and show a short message instead. Run a reconciliation pass comparing reservations against the calendar and report both directions of drift. Export reservations and settings, and rate limit the public page. Trap: Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. Done when: With the refresh token revoked and the cache past its maximum age, the public page offers no slots and states why, tested by revoking. A reservation whose calendar event was deleted outside the app is reported by the reconciliation pass, and so is a calendar event with no reservation. The public page is rate limited per address and the limit is stated in the README. Export produces one file of reservations, event types and availability that reimports into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which multiply every availability query by a permission model. - Payments at booking, which adds refunds, disputes and a second set of webhooks that must also happen exactly once. - Reading event contents to be clever about your day; freeBusy gives busy ranges and that is deliberately all this build can see. - A second calendar provider; one done correctly beats two done nearly, and the second one is mostly a rewrite of the timezone handling. - Hosting booking pages for other people, which makes you the custodian of their calendar tokens and turns this into a security product. ### README must contain - That availability comes from freeBusy only, so no event title, description or attendee ever enters the database - That the calendar event id is derived from the reservation and uses the base32hex alphabet, with the length limits stated - That the local unique constraint is the booking lock, because a duplicate event id may not be rejected by the calendar - That the page stops offering slots when the busy cache is stale and the refresh has failed - How cancel and reschedule tokens are issued, hashed and expired ===== AGENTS.md ===== # Agent instructions · zcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process, SQLite, Google Calendar freeBusy only, never events.list, Instants stored in UTC, zones resolved from the IANA database at display, A local reservation row with a unique constraint, taken before the calendar is touched, events.insert with a client-supplied base32hex id derived from the reservation, One provider behind one send function, with a calendar invitation attached. 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 - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ===== BUILD_PLAN.md ===== # Build plan · zcal A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. 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, without reading anyone's calendar Availability is computed from busy ranges, and no event title ever enters your database. ### Steps 1. Use freeBusy and prove nothing else is stored The test is the point: a grep over the schema and the log output. Files: `src/calendar/freebusy.ts`, `tests/no-event-contents.test.ts` 2. Apply buffers and notice before offering a slot A slot you cannot actually take is worse than no slot. Files: `src/availability/compute.ts`, `tests/buffers.test.ts` 3. Batch past the calendar expansion ceiling Silent truncation shows you as free during a meeting. Files: `src/calendar/freebusy.ts` ### Done when - [ ] No event title or description is ever stored or logged - [ ] A private event blocks its slot - [ ] More than 50 calendars are batched, not truncated - [ ] Buffers, notice and horizon each change the offered slots ### Watch out - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. ## Phase 2 · Two timezones and the two awkward days A slot shown to the invitee in their zone is the same instant you hold in yours. ### Steps 1. Compute day boundaries from zone rules per date A fixed offset is correct for about ten months a year. Files: `src/availability/zones.ts`, `tests/dst.test.ts` 2. Show both local times on every slot The commonest scheduling complaint is someone turning up an hour out. Files: `src/templates/book.html` 3. Store instants and derive every display One representation in the database, many on the screen. Files: `src/db/schema.sql` ### Done when - [ ] Both clock change days resolve by the stated rule - [ ] Two zones changing on different dates both render correctly - [ ] Stored instants and displayed times agree - [ ] Changing the displayed zone does not change the slot set ### Watch out - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. ## Phase 3 · One slot, one booking Two people clicking the same slot produce one event and one clear refusal. ### Steps 1. Take the unique reservation row first Before the network. This is the actual lock. Files: `src/booking/reserve.ts`, `tests/race.test.ts` 2. Derive a valid base32hex event id A hyphenated hexadecimal id is rejected by the alphabet rule. Files: `src/calendar/event-id.ts`, `tests/event-id.test.ts` 3. Settle a timeout with a get, never a second insert The reference says a duplicate id may not be caught. Files: `src/calendar/reconcile.ts` ### Done when - [ ] Concurrent bookings of one slot produce one event - [ ] A timeout is settled by fetching the derived id - [ ] The derived id is valid base32hex of legal length - [ ] The losing booker sees a refreshed slot list ### Watch out - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. ## Phase 4 · Confirmation, reschedule and cancel Both sides get a correct confirmation and can change it without guessing a URL. ### Steps 1. Issue random single-purpose tokens, store hashes Sequential identifiers in a public URL are an invitation. Files: `src/tokens.ts`, `tests/token-guess.test.ts` 2. Make cancellation idempotent People double-click, and mail clients prefetch links. Files: `src/booking/cancel.ts` 3. Do reschedule in one transaction A half-done reschedule loses the meeting entirely. Files: `src/booking/reschedule.ts`, `tests/reschedule.test.ts` ### Done when - [ ] A guessed cancel link is refused - [ ] Raw tokens never reach the database or logs - [ ] Cancelling frees the slot and removes the event - [ ] A failed reschedule leaves the original intact ### Watch out - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ## Not in this build - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. ## After v1, if you want it - Meeting polls, which reuse the availability engine and add a voting table rather than new calendar work - A second event type with its own buffers, which the schema already supports and the interface does not yet expose - Webhook-driven cache invalidation, once the polling version has run for a month without drift ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reservations, event types, availability and the busy cache. DATABASE_PATH=./data/booking.db # Required. From the OAuth client. Must match the registered callback. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. From the OAuth client. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. Registered on the client exactly, including the scheme and path. GOOGLE_REDIRECT_URI=https://book.example.com/oauth/callback # Required. The calendar queried for busy ranges and written to. CALENDAR_ID=primary # Required. Used to build cancel and reschedule links in emails. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Confirmations and invitations. Credentials only here. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Past this age with no successful refresh, the page stops offering slots. BUSY_CACHE_MAX_AGE_SECONDS=900
You are building a lean indie version of zcal. 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 ===== # zcal · indie build A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process, SQLite | a booking page is small and all of its difficulty is in the details | | Calendar read | Google Calendar freeBusy only, never events.list | freeBusy returns busy ranges without titles, so private events stay private | | Time | Instants stored in UTC, zones resolved from the IANA database at display | almost every scheduling bug is a timezone bug | | Booking | A local reservation row with a unique constraint, taken before the calendar is touched | two people clicking the same slot is the race that matters | | Calendar write | events.insert with a client-supplied base32hex id derived from the reservation | a retry after a timeout writes the same event rather than a second one | | Mail | One provider behind one send function, with a calendar invitation attached | the confirmation is as much the product as the page is | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the calendar client in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud OAuth client for the Calendar API** · free - Why: Reading freeBusy and writing events both need an OAuth client, and the consent screen configuration decides how often your refresh token survives. - Get it: Create a project, enable the Calendar API, create an OAuth client of type web application and add your callback URL. - Verify: A consent round trip returns a refresh token and a freeBusy query succeeds - [ ] **A throwaway calendar with real-looking events** · free - Why: The privacy test and the drift test are only meaningful against a calendar containing events whose titles you would mind leaking. - Get it: Create a second calendar on the same account and add a few private events across a clock change weekend. - Verify: freeBusy returns busy ranges for it and no titles - [ ] **A host with a stable HTTPS URL** · a few pounds a month - Why: The OAuth callback and the public booking page both need an address that does not change, and the callback must match the client exactly. - Get it: A small VPS with a domain, or a tunnel with a fixed hostname for development. - Verify: The callback URL registered in the client resolves and completes consent - [ ] **An email provider for confirmations** · free tier is enough for personal use - Why: A booking nobody was told about is not a booking, and the invitation attachment is what puts it in the invitee's calendar. - Get it: Any transactional provider or an SMTP account you already own. - Verify: A confirmation arrives with a valid calendar invitation attached - [ ] **Working hours, buffers, notice and the clock change rule** · free - Why: Each one is asserted by a test, and deciding them after seeing which slots appear means choosing whatever the code already did. - Get it: Write down your hours per weekday in your zone, the buffers, the minimum notice, the horizon, and what happens to a slot in the missing hour. - Verify: All five appear in configuration and are named in the tests ## 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: - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for zcal, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · zcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that offers only the slots you are really free and cannot hand the same one to two people to replace zcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, one process, SQLite · a booking page is small and all of its difficulty is in the details - Calendar read: Google Calendar freeBusy only, never events.list · freeBusy returns busy ranges without titles, so private events stay private - Time: Instants stored in UTC, zones resolved from the IANA database at display · almost every scheduling bug is a timezone bug - Booking: A local reservation row with a unique constraint, taken before the calendar is touched · two people clicking the same slot is the race that matters - Calendar write: events.insert with a client-supplied base32hex id derived from the reservation · a retry after a timeout writes the same event rather than a second one - Mail: One provider behind one send function, with a calendar invitation attached · the confirmation is as much the product as the page is ### Data model (create this before Phase 1) - availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens. ### Phase 1 · Busy, without reading anyone's calendar Build: Connect one Google account and compute availability from the freeBusy endpoint against your working hours, buffers, minimum notice and maximum days ahead. Cache what freeBusy returned with its fetch time. Respect the documented ceiling on how many calendars one query may expand, which is 50 (checked against the current reference), and batch beyond it. Trap: Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. Done when: Availability is computed only from freeBusy, and a test asserts that no field named summary, description or attendees is ever stored or logged. A private event blocks its slot without its title appearing anywhere. A query covering more than 50 calendars is batched rather than silently truncated. Buffers, minimum notice and maximum days ahead all change the offered slots, each covered by a test. Do not build yet: No public page, booking, email or cancellation. ### Phase 2 · Two timezones and the two awkward days Build: A public page that detects the visitor's zone, lets them change it, and renders each slot as a local time in both zones. Compute your working hours from the zone rules for that date. Handle a working day that crosses a clock change in either zone, and store only the instant. Trap: Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. Done when: Slots on both clock change days in your zone resolve by a stated rule and are covered by tests. An invitee in a zone whose transition falls on a different date sees the correct local time throughout, tested with two zones that change on different days. The instant stored and the two local times displayed always agree. Changing the displayed zone never changes which slots are offered. Do not build yet: No booking, calendar write or email yet. ### Phase 3 · One slot, one booking Build: Insert a reservation row in its own transaction, with a unique constraint on event type and slot start, before any calendar call. Then call events.insert with an id derived from the reservation in the base32hex alphabet. On a timeout, settle by fetching the event by that id rather than inserting again. The loser of the race gets a refreshed slot list, not an error page. Trap: Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. Done when: Two concurrent bookings of one slot produce one reservation and one calendar event, tested with parallel requests. A calendar call that times out is settled by fetching the derived id, and a second insert is never issued, tested with a fake that accepts then hangs. The derived id uses only lowercase a to v and digits and is between 5 and 1024 characters, asserted in the suite. The losing booker is shown the updated slot list rather than a failure. Do not build yet: No email, reschedule or cancellation yet. ### Phase 4 · Confirmation, reschedule and cancel Build: Send both parties a confirmation carrying a calendar invitation and both local times. Issue a cancel link and a reschedule link as single-purpose tokens stored hashed with an expiry. Cancelling frees the slot and removes the calendar event. Rescheduling is a cancel and a book inside one transaction, so a failure leaves the original booking intact. Trap: Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. Done when: A cancel link built from a guessed or incremented identifier is refused, tested directly. Tokens are stored hashed and a raw token never appears in the database or in logs, verified with a canary. Cancelling frees the slot within the same request and removes the calendar event. A reschedule whose booking half fails leaves the original reservation and its calendar event untouched, tested by injecting a failure. Do not build yet: No token expiry handling, reconciliation or export yet. ### Phase 5 · When the token dies and the calendar drifts Build: Refresh the access token and treat a refresh failure as a state, not an exception. If the busy cache is older than a stated age and a refresh fails, stop offering slots and show a short message instead. Run a reconciliation pass comparing reservations against the calendar and report both directions of drift. Export reservations and settings, and rate limit the public page. Trap: Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. Done when: With the refresh token revoked and the cache past its maximum age, the public page offers no slots and states why, tested by revoking. A reservation whose calendar event was deleted outside the app is reported by the reconciliation pass, and so is a calendar event with no reservation. The public page is rate limited per address and the limit is stated in the README. Export produces one file of reservations, event types and availability that reimports into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which multiply every availability query by a permission model. - Payments at booking, which adds refunds, disputes and a second set of webhooks that must also happen exactly once. - Reading event contents to be clever about your day; freeBusy gives busy ranges and that is deliberately all this build can see. - A second calendar provider; one done correctly beats two done nearly, and the second one is mostly a rewrite of the timezone handling. - Hosting booking pages for other people, which makes you the custodian of their calendar tokens and turns this into a security product. ### README must contain - That availability comes from freeBusy only, so no event title, description or attendee ever enters the database - That the calendar event id is derived from the reservation and uses the base32hex alphabet, with the length limits stated - That the local unique constraint is the booking lock, because a duplicate event id may not be rejected by the calendar - That the page stops offering slots when the busy cache is stale and the refresh has failed - How cancel and reschedule tokens are issued, hashed and expired ===== AGENTS.md ===== # Agent instructions · zcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process, SQLite, Google Calendar freeBusy only, never events.list, Instants stored in UTC, zones resolved from the IANA database at display, A local reservation row with a unique constraint, taken before the calendar is touched, events.insert with a client-supplied base32hex id derived from the reservation, One provider behind one send function, with a calendar invitation attached. 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 - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ===== BUILD_PLAN.md ===== # Build plan · zcal A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. 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, without reading anyone's calendar Availability is computed from busy ranges, and no event title ever enters your database. ### Steps 1. Use freeBusy and prove nothing else is stored The test is the point: a grep over the schema and the log output. Files: `src/calendar/freebusy.ts`, `tests/no-event-contents.test.ts` 2. Apply buffers and notice before offering a slot A slot you cannot actually take is worse than no slot. Files: `src/availability/compute.ts`, `tests/buffers.test.ts` 3. Batch past the calendar expansion ceiling Silent truncation shows you as free during a meeting. Files: `src/calendar/freebusy.ts` ### Done when - [ ] No event title or description is ever stored or logged - [ ] A private event blocks its slot - [ ] More than 50 calendars are batched, not truncated - [ ] Buffers, notice and horizon each change the offered slots ### Watch out - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. ## Phase 2 · Two timezones and the two awkward days A slot shown to the invitee in their zone is the same instant you hold in yours. ### Steps 1. Compute day boundaries from zone rules per date A fixed offset is correct for about ten months a year. Files: `src/availability/zones.ts`, `tests/dst.test.ts` 2. Show both local times on every slot The commonest scheduling complaint is someone turning up an hour out. Files: `src/templates/book.html` 3. Store instants and derive every display One representation in the database, many on the screen. Files: `src/db/schema.sql` ### Done when - [ ] Both clock change days resolve by the stated rule - [ ] Two zones changing on different dates both render correctly - [ ] Stored instants and displayed times agree - [ ] Changing the displayed zone does not change the slot set ### Watch out - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. ## Phase 3 · One slot, one booking Two people clicking the same slot produce one event and one clear refusal. ### Steps 1. Take the unique reservation row first Before the network. This is the actual lock. Files: `src/booking/reserve.ts`, `tests/race.test.ts` 2. Derive a valid base32hex event id A hyphenated hexadecimal id is rejected by the alphabet rule. Files: `src/calendar/event-id.ts`, `tests/event-id.test.ts` 3. Settle a timeout with a get, never a second insert The reference says a duplicate id may not be caught. Files: `src/calendar/reconcile.ts` ### Done when - [ ] Concurrent bookings of one slot produce one event - [ ] A timeout is settled by fetching the derived id - [ ] The derived id is valid base32hex of legal length - [ ] The losing booker sees a refreshed slot list ### Watch out - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. ## Phase 4 · Confirmation, reschedule and cancel Both sides get a correct confirmation and can change it without guessing a URL. ### Steps 1. Issue random single-purpose tokens, store hashes Sequential identifiers in a public URL are an invitation. Files: `src/tokens.ts`, `tests/token-guess.test.ts` 2. Make cancellation idempotent People double-click, and mail clients prefetch links. Files: `src/booking/cancel.ts` 3. Do reschedule in one transaction A half-done reschedule loses the meeting entirely. Files: `src/booking/reschedule.ts`, `tests/reschedule.test.ts` ### Done when - [ ] A guessed cancel link is refused - [ ] Raw tokens never reach the database or logs - [ ] Cancelling frees the slot and removes the event - [ ] A failed reschedule leaves the original intact ### Watch out - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ## Not in this build - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. ## After v1, if you want it - Meeting polls, which reuse the availability engine and add a voting table rather than new calendar work - A second event type with its own buffers, which the schema already supports and the interface does not yet expose - Webhook-driven cache invalidation, once the polling version has run for a month without drift ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reservations, event types, availability and the busy cache. DATABASE_PATH=./data/booking.db # Required. From the OAuth client. Must match the registered callback. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. From the OAuth client. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. Registered on the client exactly, including the scheme and path. GOOGLE_REDIRECT_URI=https://book.example.com/oauth/callback # Required. The calendar queried for busy ranges and written to. CALENDAR_ID=primary # Required. Used to build cancel and reschedule links in emails. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Confirmations and invitations. Credentials only here. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Past this age with no successful refresh, the page stops offering slots. BUSY_CACHE_MAX_AGE_SECONDS=900
You are building a production product version of zcal. 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 ===== # zcal · product brief ## Problem zcal's solo core is compact: build a personal freeform scheduling tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability. ## Product outcome A booking page that never sees the contents of your calendar, shows the right local time to someone in another country on the week the clocks change, gives one slot to exactly one person, and refuses to guess when its connection to the calendar has gone stale. ## 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 - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness ## Success criteria - No event title or description is confirmed stored or logged - Both clock change days are confirmed to resolve by the stated rule - Concurrent bookings of one slot are confirmed to produce one event - A guessed cancel link is confirmed refused - A revoked refresh token is confirmed to stop slots being offered ===== BRIEF.md ===== # Build brief · zcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that offers only the slots you are really free and cannot hand the same one to two people to replace zcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, one process, SQLite · a booking page is small and all of its difficulty is in the details - Calendar read: Google Calendar freeBusy only, never events.list · freeBusy returns busy ranges without titles, so private events stay private - Time: Instants stored in UTC, zones resolved from the IANA database at display · almost every scheduling bug is a timezone bug - Booking: A local reservation row with a unique constraint, taken before the calendar is touched · two people clicking the same slot is the race that matters - Calendar write: events.insert with a client-supplied base32hex id derived from the reservation · a retry after a timeout writes the same event rather than a second one - Mail: One provider behind one send function, with a calendar invitation attached · the confirmation is as much the product as the page is ### Data model (create this before Phase 1) - availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens. ### Phase 1 · Busy, without reading anyone's calendar Build: Connect one Google account and compute availability from the freeBusy endpoint against your working hours, buffers, minimum notice and maximum days ahead. Cache what freeBusy returned with its fetch time. Respect the documented ceiling on how many calendars one query may expand, which is 50 (checked against the current reference), and batch beyond it. Trap: Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. Done when: Availability is computed only from freeBusy, and a test asserts that no field named summary, description or attendees is ever stored or logged. A private event blocks its slot without its title appearing anywhere. A query covering more than 50 calendars is batched rather than silently truncated. Buffers, minimum notice and maximum days ahead all change the offered slots, each covered by a test. Do not build yet: No public page, booking, email or cancellation. ### Phase 2 · Two timezones and the two awkward days Build: A public page that detects the visitor's zone, lets them change it, and renders each slot as a local time in both zones. Compute your working hours from the zone rules for that date. Handle a working day that crosses a clock change in either zone, and store only the instant. Trap: Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. Done when: Slots on both clock change days in your zone resolve by a stated rule and are covered by tests. An invitee in a zone whose transition falls on a different date sees the correct local time throughout, tested with two zones that change on different days. The instant stored and the two local times displayed always agree. Changing the displayed zone never changes which slots are offered. Do not build yet: No booking, calendar write or email yet. ### Phase 3 · One slot, one booking Build: Insert a reservation row in its own transaction, with a unique constraint on event type and slot start, before any calendar call. Then call events.insert with an id derived from the reservation in the base32hex alphabet. On a timeout, settle by fetching the event by that id rather than inserting again. The loser of the race gets a refreshed slot list, not an error page. Trap: Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. Done when: Two concurrent bookings of one slot produce one reservation and one calendar event, tested with parallel requests. A calendar call that times out is settled by fetching the derived id, and a second insert is never issued, tested with a fake that accepts then hangs. The derived id uses only lowercase a to v and digits and is between 5 and 1024 characters, asserted in the suite. The losing booker is shown the updated slot list rather than a failure. Do not build yet: No email, reschedule or cancellation yet. ### Phase 4 · Confirmation, reschedule and cancel Build: Send both parties a confirmation carrying a calendar invitation and both local times. Issue a cancel link and a reschedule link as single-purpose tokens stored hashed with an expiry. Cancelling frees the slot and removes the calendar event. Rescheduling is a cancel and a book inside one transaction, so a failure leaves the original booking intact. Trap: Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. Done when: A cancel link built from a guessed or incremented identifier is refused, tested directly. Tokens are stored hashed and a raw token never appears in the database or in logs, verified with a canary. Cancelling frees the slot within the same request and removes the calendar event. A reschedule whose booking half fails leaves the original reservation and its calendar event untouched, tested by injecting a failure. Do not build yet: No token expiry handling, reconciliation or export yet. ### Phase 5 · When the token dies and the calendar drifts Build: Refresh the access token and treat a refresh failure as a state, not an exception. If the busy cache is older than a stated age and a refresh fails, stop offering slots and show a short message instead. Run a reconciliation pass comparing reservations against the calendar and report both directions of drift. Export reservations and settings, and rate limit the public page. Trap: Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. Done when: With the refresh token revoked and the cache past its maximum age, the public page offers no slots and states why, tested by revoking. A reservation whose calendar event was deleted outside the app is reported by the reconciliation pass, and so is a calendar event with no reservation. The public page is rate limited per address and the limit is stated in the README. Export produces one file of reservations, event types and availability that reimports into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which multiply every availability query by a permission model. - Payments at booking, which adds refunds, disputes and a second set of webhooks that must also happen exactly once. - Reading event contents to be clever about your day; freeBusy gives busy ranges and that is deliberately all this build can see. - A second calendar provider; one done correctly beats two done nearly, and the second one is mostly a rewrite of the timezone handling. - Hosting booking pages for other people, which makes you the custodian of their calendar tokens and turns this into a security product. ### README must contain - That availability comes from freeBusy only, so no event title, description or attendee ever enters the database - That the calendar event id is derived from the reservation and uses the base32hex alphabet, with the length limits stated - That the local unique constraint is the booking lock, because a duplicate event id may not be rejected by the calendar - That the page stops offering slots when the busy cache is stale and the refresh has failed - How cancel and reschedule tokens are issued, hashed and expired ===== ARCHITECTURE.md ===== # Architecture · zcal ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process, SQLite | a booking page is small and all of its difficulty is in the details | | Calendar read | Google Calendar freeBusy only, never events.list | freeBusy returns busy ranges without titles, so private events stay private | | Time | Instants stored in UTC, zones resolved from the IANA database at display | almost every scheduling bug is a timezone bug | | Booking | A local reservation row with a unique constraint, taken before the calendar is touched | two people clicking the same slot is the race that matters | | Calendar write | events.insert with a client-supplied base32hex id derived from the reservation | a retry after a timeout writes the same event rather than a second one | | Mail | One provider behind one send function, with a calendar invitation attached | the confirmation is as much the product as the page is | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | freeBusy, buffers, notice and the horizon. | Any provider whose API returns busy ranges without contents. | | Zones | Day boundaries and dual-zone rendering. | Any date library with a current IANA database, provided both transition days stay tested. | | Booking | The unique reservation row and the race. | Not swappable for an application-level check, which loses the race under concurrency. | | Calendar write | The derived event id and timeout settlement. | Any provider, provided a timeout is settled by reading rather than writing again. | | Tokens | Hashed single-purpose cancel and reschedule links. | Any scheme, provided the raw token is never stored. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Reservations, event types, availability and the busy cache. - `GOOGLE_CLIENT_ID` · required · From the OAuth client. Must match the registered callback. - `GOOGLE_CLIENT_SECRET` · required, secret · From the OAuth client. Never in the repository. - `GOOGLE_REDIRECT_URI` · required · Registered on the client exactly, including the scheme and path. - `CALENDAR_ID` · required · The calendar queried for busy ranges and written to. - `PUBLIC_BASE_URL` · required · Used to build cancel and reschedule links in emails. - `SMTP_URL` · required, secret · Confirmations and invitations. Credentials only here. - `BUSY_CACHE_MAX_AGE_SECONDS` · required · Past this age with no successful refresh, the page stops offering slots. ## 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 · zcal product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, one process, SQLite, Google Calendar freeBusy only, never events.list, Instants stored in UTC, zones resolved from the IANA database at display, A local reservation row with a unique constraint, taken before the calendar is touched, events.insert with a client-supplied base32hex id derived from the reservation, One provider behind one send function, with a calendar invitation attached. - 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 - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. - Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. ===== MILESTONES.md ===== # Delivery milestones · zcal Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Busy, without reading anyone's calendar Availability is computed from busy ranges, and no event title ever enters your database. ### Steps 1. Use freeBusy and prove nothing else is stored The test is the point: a grep over the schema and the log output. Files: `src/calendar/freebusy.ts`, `tests/no-event-contents.test.ts` 2. Apply buffers and notice before offering a slot A slot you cannot actually take is worse than no slot. Files: `src/availability/compute.ts`, `tests/buffers.test.ts` 3. Batch past the calendar expansion ceiling Silent truncation shows you as free during a meeting. Files: `src/calendar/freebusy.ts` ### Done when - [ ] No event title or description is ever stored or logged - [ ] A private event blocks its slot - [ ] More than 50 calendars are batched, not truncated - [ ] Buffers, notice and horizon each change the offered slots ### Watch out - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. ## M2 · Two timezones and the two awkward days A slot shown to the invitee in their zone is the same instant you hold in yours. ### Steps 1. Compute day boundaries from zone rules per date A fixed offset is correct for about ten months a year. Files: `src/availability/zones.ts`, `tests/dst.test.ts` 2. Show both local times on every slot The commonest scheduling complaint is someone turning up an hour out. Files: `src/templates/book.html` 3. Store instants and derive every display One representation in the database, many on the screen. Files: `src/db/schema.sql` ### Done when - [ ] Both clock change days resolve by the stated rule - [ ] Two zones changing on different dates both render correctly - [ ] Stored instants and displayed times agree - [ ] Changing the displayed zone does not change the slot set ### Watch out - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. ## M3 · One slot, one booking Two people clicking the same slot produce one event and one clear refusal. ### Steps 1. Take the unique reservation row first Before the network. This is the actual lock. Files: `src/booking/reserve.ts`, `tests/race.test.ts` 2. Derive a valid base32hex event id A hyphenated hexadecimal id is rejected by the alphabet rule. Files: `src/calendar/event-id.ts`, `tests/event-id.test.ts` 3. Settle a timeout with a get, never a second insert The reference says a duplicate id may not be caught. Files: `src/calendar/reconcile.ts` ### Done when - [ ] Concurrent bookings of one slot produce one event - [ ] A timeout is settled by fetching the derived id - [ ] The derived id is valid base32hex of legal length - [ ] The losing booker sees a refreshed slot list ### Watch out - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. ## M4 · Confirmation, reschedule and cancel Both sides get a correct confirmation and can change it without guessing a URL. ### Steps 1. Issue random single-purpose tokens, store hashes Sequential identifiers in a public URL are an invitation. Files: `src/tokens.ts`, `tests/token-guess.test.ts` 2. Make cancellation idempotent People double-click, and mail clients prefetch links. Files: `src/booking/cancel.ts` 3. Do reschedule in one transaction A half-done reschedule loses the meeting entirely. Files: `src/booking/reschedule.ts`, `tests/reschedule.test.ts` ### Done when - [ ] A guessed cancel link is refused - [ ] Raw tokens never reach the database or logs - [ ] Cancelling frees the slot and removes the event - [ ] A failed reschedule leaves the original intact ### Watch out - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ## M5 · When the token dies and the calendar drifts (production only) A stale or broken connection stops offering slots instead of offering wrong ones. ### Steps 1. Gate the page on cache age and refresh health An unavailable page beats a wrong slot. Files: `src/calendar/auth.ts`, `tests/stale-cache.test.ts` 2. Reconcile both directions of drift Events vanish from calendars for reasons you will not see. Files: `src/calendar/reconcile.ts` 3. Rate limit the public page It is the one endpoint strangers can reach. Files: `src/middleware/ratelimit.ts` ### Done when - [ ] A revoked refresh token stops slots being offered - [ ] Drift is reported in both directions - [ ] The public page is rate limited and documented - [ ] Export round-trips into an empty database ### Watch out - Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. ===== OPERATIONS.md ===== # Operations · zcal ## Backup The database holds the reservations and the tokens; the calendar holds the events. Back up the database daily, because a restore to a point before a booking leaves a calendar event with no reservation, which the reconciliation pass will then report. ## Restore Restore, then run reconciliation before reopening the page, so drift in both directions is reported before anyone books on top of it. Expect cancel tokens issued after the backup to be invalid, and say so in the email footer. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Refresh failures, the age of the busy cache, reservations without a confirmed calendar write, and the rate limiter's rejections. A silent refresh failure is the one that produces double bookings. ## Incident checklist A double booking means the unique constraint was not in force, and the reservations table will show two rows or one row with two calendar writes. A missing meeting means the calendar event was deleted elsewhere, which reconciliation reports and the confirmation email can prove. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No event title or description is confirmed stored or logged - [ ] Both clock change days are confirmed to resolve by the stated rule - [ ] Concurrent bookings of one slot are confirmed to produce one event - [ ] A guessed cancel link is confirmed refused - [ ] A revoked refresh token is confirmed to stop slots being offered ## Launch constraint Do not market omitted zcal 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. Reservations, event types, availability and the busy cache. DATABASE_PATH=./data/booking.db # Required. From the OAuth client. Must match the registered callback. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. From the OAuth client. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. Registered on the client exactly, including the scheme and path. GOOGLE_REDIRECT_URI=https://book.example.com/oauth/callback # Required. The calendar queried for busy ranges and written to. CALENDAR_ID=primary # Required. Used to build cancel and reschedule links in emails. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Confirmations and invitations. Credentials only here. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Past this age with no successful refresh, the page stops offering slots. BUSY_CACHE_MAX_AGE_SECONDS=900
# zcal · indie build A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process, SQLite | a booking page is small and all of its difficulty is in the details | | Calendar read | Google Calendar freeBusy only, never events.list | freeBusy returns busy ranges without titles, so private events stay private | | Time | Instants stored in UTC, zones resolved from the IANA database at display | almost every scheduling bug is a timezone bug | | Booking | A local reservation row with a unique constraint, taken before the calendar is touched | two people clicking the same slot is the race that matters | | Calendar write | events.insert with a client-supplied base32hex id derived from the reservation | a retry after a timeout writes the same event rather than a second one | | Mail | One provider behind one send function, with a calendar invitation attached | the confirmation is as much the product as the page is | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the booking page and the calendar client in one process. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **A Google Cloud OAuth client for the Calendar API** · free - Why: Reading freeBusy and writing events both need an OAuth client, and the consent screen configuration decides how often your refresh token survives. - Get it: Create a project, enable the Calendar API, create an OAuth client of type web application and add your callback URL. - Verify: A consent round trip returns a refresh token and a freeBusy query succeeds - [ ] **A throwaway calendar with real-looking events** · free - Why: The privacy test and the drift test are only meaningful against a calendar containing events whose titles you would mind leaking. - Get it: Create a second calendar on the same account and add a few private events across a clock change weekend. - Verify: freeBusy returns busy ranges for it and no titles - [ ] **A host with a stable HTTPS URL** · a few pounds a month - Why: The OAuth callback and the public booking page both need an address that does not change, and the callback must match the client exactly. - Get it: A small VPS with a domain, or a tunnel with a fixed hostname for development. - Verify: The callback URL registered in the client resolves and completes consent - [ ] **An email provider for confirmations** · free tier is enough for personal use - Why: A booking nobody was told about is not a booking, and the invitation attachment is what puts it in the invitee's calendar. - Get it: Any transactional provider or an SMTP account you already own. - Verify: A confirmation arrives with a valid calendar invitation attached - [ ] **Working hours, buffers, notice and the clock change rule** · free - Why: Each one is asserted by a test, and deciding them after seeing which slots appear means choosing whatever the code already did. - Get it: Write down your hours per weekday in your zone, the buffers, the minimum notice, the horizon, and what happens to a slot in the missing hour. - Verify: All five appear in configuration and are named in the tests ## 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: - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness If one of those is essential to you, that is the reason to keep paying for zcal, and the README should say so rather than pretend.
# Build brief · zcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that offers only the slots you are really free and cannot hand the same one to two people to replace zcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, one process, SQLite · a booking page is small and all of its difficulty is in the details - Calendar read: Google Calendar freeBusy only, never events.list · freeBusy returns busy ranges without titles, so private events stay private - Time: Instants stored in UTC, zones resolved from the IANA database at display · almost every scheduling bug is a timezone bug - Booking: A local reservation row with a unique constraint, taken before the calendar is touched · two people clicking the same slot is the race that matters - Calendar write: events.insert with a client-supplied base32hex id derived from the reservation · a retry after a timeout writes the same event rather than a second one - Mail: One provider behind one send function, with a calendar invitation attached · the confirmation is as much the product as the page is ### Data model (create this before Phase 1) - availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens. ### Phase 1 · Busy, without reading anyone's calendar Build: Connect one Google account and compute availability from the freeBusy endpoint against your working hours, buffers, minimum notice and maximum days ahead. Cache what freeBusy returned with its fetch time. Respect the documented ceiling on how many calendars one query may expand, which is 50 (checked against the current reference), and batch beyond it. Trap: Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. Done when: Availability is computed only from freeBusy, and a test asserts that no field named summary, description or attendees is ever stored or logged. A private event blocks its slot without its title appearing anywhere. A query covering more than 50 calendars is batched rather than silently truncated. Buffers, minimum notice and maximum days ahead all change the offered slots, each covered by a test. Do not build yet: No public page, booking, email or cancellation. ### Phase 2 · Two timezones and the two awkward days Build: A public page that detects the visitor's zone, lets them change it, and renders each slot as a local time in both zones. Compute your working hours from the zone rules for that date. Handle a working day that crosses a clock change in either zone, and store only the instant. Trap: Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. Done when: Slots on both clock change days in your zone resolve by a stated rule and are covered by tests. An invitee in a zone whose transition falls on a different date sees the correct local time throughout, tested with two zones that change on different days. The instant stored and the two local times displayed always agree. Changing the displayed zone never changes which slots are offered. Do not build yet: No booking, calendar write or email yet. ### Phase 3 · One slot, one booking Build: Insert a reservation row in its own transaction, with a unique constraint on event type and slot start, before any calendar call. Then call events.insert with an id derived from the reservation in the base32hex alphabet. On a timeout, settle by fetching the event by that id rather than inserting again. The loser of the race gets a refreshed slot list, not an error page. Trap: Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. Done when: Two concurrent bookings of one slot produce one reservation and one calendar event, tested with parallel requests. A calendar call that times out is settled by fetching the derived id, and a second insert is never issued, tested with a fake that accepts then hangs. The derived id uses only lowercase a to v and digits and is between 5 and 1024 characters, asserted in the suite. The losing booker is shown the updated slot list rather than a failure. Do not build yet: No email, reschedule or cancellation yet. ### Phase 4 · Confirmation, reschedule and cancel Build: Send both parties a confirmation carrying a calendar invitation and both local times. Issue a cancel link and a reschedule link as single-purpose tokens stored hashed with an expiry. Cancelling frees the slot and removes the calendar event. Rescheduling is a cancel and a book inside one transaction, so a failure leaves the original booking intact. Trap: Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. Done when: A cancel link built from a guessed or incremented identifier is refused, tested directly. Tokens are stored hashed and a raw token never appears in the database or in logs, verified with a canary. Cancelling frees the slot within the same request and removes the calendar event. A reschedule whose booking half fails leaves the original reservation and its calendar event untouched, tested by injecting a failure. Do not build yet: No token expiry handling, reconciliation or export yet. ### Phase 5 · When the token dies and the calendar drifts Build: Refresh the access token and treat a refresh failure as a state, not an exception. If the busy cache is older than a stated age and a refresh fails, stop offering slots and show a short message instead. Run a reconciliation pass comparing reservations against the calendar and report both directions of drift. Export reservations and settings, and rate limit the public page. Trap: Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. Done when: With the refresh token revoked and the cache past its maximum age, the public page offers no slots and states why, tested by revoking. A reservation whose calendar event was deleted outside the app is reported by the reconciliation pass, and so is a calendar event with no reservation. The public page is rate limited per address and the limit is stated in the README. Export produces one file of reservations, event types and availability that reimports into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which multiply every availability query by a permission model. - Payments at booking, which adds refunds, disputes and a second set of webhooks that must also happen exactly once. - Reading event contents to be clever about your day; freeBusy gives busy ranges and that is deliberately all this build can see. - A second calendar provider; one done correctly beats two done nearly, and the second one is mostly a rewrite of the timezone handling. - Hosting booking pages for other people, which makes you the custodian of their calendar tokens and turns this into a security product. ### README must contain - That availability comes from freeBusy only, so no event title, description or attendee ever enters the database - That the calendar event id is derived from the reservation and uses the base32hex alphabet, with the length limits stated - That the local unique constraint is the booking lock, because a duplicate event id may not be rejected by the calendar - That the page stops offering slots when the busy cache is stale and the refresh has failed - How cancel and reschedule tokens are issued, hashed and expired
# Agent instructions · zcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript on Node 22, one process, SQLite, Google Calendar freeBusy only, never events.list, Instants stored in UTC, zones resolved from the IANA database at display, A local reservation row with a unique constraint, taken before the calendar is touched, events.insert with a client-supplied base32hex id derived from the reservation, One provider behind one send function, with a calendar invitation attached. 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 - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error.
# Build plan · zcal A personal booking page whose hard problems are computing availability without copying your calendar's contents, rendering slots correctly across two timezones and a clock change, and turning a click into exactly one event even when the calendar call times out. 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, without reading anyone's calendar Availability is computed from busy ranges, and no event title ever enters your database. ### Steps 1. Use freeBusy and prove nothing else is stored The test is the point: a grep over the schema and the log output. Files: `src/calendar/freebusy.ts`, `tests/no-event-contents.test.ts` 2. Apply buffers and notice before offering a slot A slot you cannot actually take is worse than no slot. Files: `src/availability/compute.ts`, `tests/buffers.test.ts` 3. Batch past the calendar expansion ceiling Silent truncation shows you as free during a meeting. Files: `src/calendar/freebusy.ts` ### Done when - [ ] No event title or description is ever stored or logged - [ ] A private event blocks its slot - [ ] More than 50 calendars are batched, not truncated - [ ] Buffers, notice and horizon each change the offered slots ### Watch out - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. ## Phase 2 · Two timezones and the two awkward days A slot shown to the invitee in their zone is the same instant you hold in yours. ### Steps 1. Compute day boundaries from zone rules per date A fixed offset is correct for about ten months a year. Files: `src/availability/zones.ts`, `tests/dst.test.ts` 2. Show both local times on every slot The commonest scheduling complaint is someone turning up an hour out. Files: `src/templates/book.html` 3. Store instants and derive every display One representation in the database, many on the screen. Files: `src/db/schema.sql` ### Done when - [ ] Both clock change days resolve by the stated rule - [ ] Two zones changing on different dates both render correctly - [ ] Stored instants and displayed times agree - [ ] Changing the displayed zone does not change the slot set ### Watch out - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. ## Phase 3 · One slot, one booking Two people clicking the same slot produce one event and one clear refusal. ### Steps 1. Take the unique reservation row first Before the network. This is the actual lock. Files: `src/booking/reserve.ts`, `tests/race.test.ts` 2. Derive a valid base32hex event id A hyphenated hexadecimal id is rejected by the alphabet rule. Files: `src/calendar/event-id.ts`, `tests/event-id.test.ts` 3. Settle a timeout with a get, never a second insert The reference says a duplicate id may not be caught. Files: `src/calendar/reconcile.ts` ### Done when - [ ] Concurrent bookings of one slot produce one event - [ ] A timeout is settled by fetching the derived id - [ ] The derived id is valid base32hex of legal length - [ ] The losing booker sees a refreshed slot list ### Watch out - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. ## Phase 4 · Confirmation, reschedule and cancel Both sides get a correct confirmation and can change it without guessing a URL. ### Steps 1. Issue random single-purpose tokens, store hashes Sequential identifiers in a public URL are an invitation. Files: `src/tokens.ts`, `tests/token-guess.test.ts` 2. Make cancellation idempotent People double-click, and mail clients prefetch links. Files: `src/booking/cancel.ts` 3. Do reschedule in one transaction A half-done reschedule loses the meeting entirely. Files: `src/booking/reschedule.ts`, `tests/reschedule.test.ts` ### Done when - [ ] A guessed cancel link is refused - [ ] Raw tokens never reach the database or logs - [ ] Cancelling frees the slot and removes the event - [ ] A failed reschedule leaves the original intact ### Watch out - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ## Not in this build - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. ## After v1, if you want it - Meeting polls, which reuse the availability engine and add a voting table rather than new calendar work - A second event type with its own buffers, which the schema already supports and the interface does not yet expose - Webhook-driven cache invalidation, once the polling version has run for a month without drift
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Reservations, event types, availability and the busy cache. DATABASE_PATH=./data/booking.db # Required. From the OAuth client. Must match the registered callback. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. From the OAuth client. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. Registered on the client exactly, including the scheme and path. GOOGLE_REDIRECT_URI=https://book.example.com/oauth/callback # Required. The calendar queried for busy ranges and written to. CALENDAR_ID=primary # Required. Used to build cancel and reschedule links in emails. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Confirmations and invitations. Credentials only here. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Past this age with no successful refresh, the page stops offering slots. BUSY_CACHE_MAX_AGE_SECONDS=900
# zcal · product brief ## Problem zcal's solo core is compact: build a personal freeform scheduling tool that reads one calendar, publishes availability, reserves a slot, and writes the event back. A competent builder can reach a useful personal version in one sitting, while the paid product mainly wins on OAuth, calendar reliability. ## Product outcome A booking page that never sees the contents of your calendar, shows the right local time to someone in another country on the week the clocks change, gives one slot to exactly one person, and refuses to guess when its connection to the calendar has gone stale. ## 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 - Teams, routing and round robin. - Payments at booking. - A second calendar provider. - Hosted booking pages for other people. - multi-calendar conflict handling - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness ## Success criteria - No event title or description is confirmed stored or logged - Both clock change days are confirmed to resolve by the stated rule - Concurrent bookings of one slot are confirmed to produce one event - A guessed cancel link is confirmed refused - A revoked refresh token is confirmed to stop slots being offered
# Build brief · zcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a booking page that offers only the slots you are really free and cannot hand the same one to two people to replace zcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: TypeScript on Node 22, one process, SQLite · a booking page is small and all of its difficulty is in the details - Calendar read: Google Calendar freeBusy only, never events.list · freeBusy returns busy ranges without titles, so private events stay private - Time: Instants stored in UTC, zones resolved from the IANA database at display · almost every scheduling bug is a timezone bug - Booking: A local reservation row with a unique constraint, taken before the calendar is touched · two people clicking the same slot is the race that matters - Calendar write: events.insert with a client-supplied base32hex id derived from the reservation · a retry after a timeout writes the same event rather than a second one - Mail: One provider behind one send function, with a calendar invitation attached · the confirmation is as much the product as the page is ### Data model (create this before Phase 1) - availability(weekday, start_local, end_local, timezone) · your working hours in your own zone, never in UTC - event_types(id, slug, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_notice_minutes, max_days_ahead) - busy_cache(calendar_id, window_start, window_end, ranges_json, fetched_at) · what freeBusy returned, with its age - reservations(id, event_type_id, slot_start_utc, slot_end_utc, invitee_name, invitee_email, invitee_timezone, state, created_at) · unique on (event_type_id, slot_start_utc) - calendar_writes(reservation_id, google_event_id, attempted_at, confirmed_at, last_error) · the id is derived from the reservation so a retry targets the same event - cancel_tokens(reservation_id, token_hash, purpose, expires_at) · hashed, single purpose, never the reservation id in a URL - google_event_id uses the base32hex alphabet only, lowercase a to v and the digits 0 to 9, between 5 and 1024 characters, which rules out a plain hexadecimal UUID with hyphens. ### Phase 1 · Busy, without reading anyone's calendar Build: Connect one Google account and compute availability from the freeBusy endpoint against your working hours, buffers, minimum notice and maximum days ahead. Cache what freeBusy returned with its fetch time. Respect the documented ceiling on how many calendars one query may expand, which is 50 (checked against the current reference), and batch beyond it. Trap: Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. Done when: Availability is computed only from freeBusy, and a test asserts that no field named summary, description or attendees is ever stored or logged. A private event blocks its slot without its title appearing anywhere. A query covering more than 50 calendars is batched rather than silently truncated. Buffers, minimum notice and maximum days ahead all change the offered slots, each covered by a test. Do not build yet: No public page, booking, email or cancellation. ### Phase 2 · Two timezones and the two awkward days Build: A public page that detects the visitor's zone, lets them change it, and renders each slot as a local time in both zones. Compute your working hours from the zone rules for that date. Handle a working day that crosses a clock change in either zone, and store only the instant. Trap: Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. Done when: Slots on both clock change days in your zone resolve by a stated rule and are covered by tests. An invitee in a zone whose transition falls on a different date sees the correct local time throughout, tested with two zones that change on different days. The instant stored and the two local times displayed always agree. Changing the displayed zone never changes which slots are offered. Do not build yet: No booking, calendar write or email yet. ### Phase 3 · One slot, one booking Build: Insert a reservation row in its own transaction, with a unique constraint on event type and slot start, before any calendar call. Then call events.insert with an id derived from the reservation in the base32hex alphabet. On a timeout, settle by fetching the event by that id rather than inserting again. The loser of the race gets a refreshed slot list, not an error page. Trap: Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. Done when: Two concurrent bookings of one slot produce one reservation and one calendar event, tested with parallel requests. A calendar call that times out is settled by fetching the derived id, and a second insert is never issued, tested with a fake that accepts then hangs. The derived id uses only lowercase a to v and digits and is between 5 and 1024 characters, asserted in the suite. The losing booker is shown the updated slot list rather than a failure. Do not build yet: No email, reschedule or cancellation yet. ### Phase 4 · Confirmation, reschedule and cancel Build: Send both parties a confirmation carrying a calendar invitation and both local times. Issue a cancel link and a reschedule link as single-purpose tokens stored hashed with an expiry. Cancelling frees the slot and removes the calendar event. Rescheduling is a cancel and a book inside one transaction, so a failure leaves the original booking intact. Trap: Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. Done when: A cancel link built from a guessed or incremented identifier is refused, tested directly. Tokens are stored hashed and a raw token never appears in the database or in logs, verified with a canary. Cancelling frees the slot within the same request and removes the calendar event. A reschedule whose booking half fails leaves the original reservation and its calendar event untouched, tested by injecting a failure. Do not build yet: No token expiry handling, reconciliation or export yet. ### Phase 5 · When the token dies and the calendar drifts Build: Refresh the access token and treat a refresh failure as a state, not an exception. If the busy cache is older than a stated age and a refresh fails, stop offering slots and show a short message instead. Run a reconciliation pass comparing reservations against the calendar and report both directions of drift. Export reservations and settings, and rate limit the public page. Trap: Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing. Done when: With the refresh token revoked and the cache past its maximum age, the public page offers no slots and states why, tested by revoking. A reservation whose calendar event was deleted outside the app is reported by the reconciliation pass, and so is a calendar event with no reservation. The public page is rate limited per address and the limit is stated in the README. Export produces one file of reservations, event types and availability that reimports into an empty database. Do not build yet: Nothing after this. ### Out of scope (and why) - Team routing, round robin and collective availability, which multiply every availability query by a permission model. - Payments at booking, which adds refunds, disputes and a second set of webhooks that must also happen exactly once. - Reading event contents to be clever about your day; freeBusy gives busy ranges and that is deliberately all this build can see. - A second calendar provider; one done correctly beats two done nearly, and the second one is mostly a rewrite of the timezone handling. - Hosting booking pages for other people, which makes you the custodian of their calendar tokens and turns this into a security product. ### README must contain - That availability comes from freeBusy only, so no event title, description or attendee ever enters the database - That the calendar event id is derived from the reservation and uses the base32hex alphabet, with the length limits stated - That the local unique constraint is the booking lock, because a duplicate event id may not be rejected by the calendar - That the page stops offering slots when the busy cache is stale and the refresh has failed - How cancel and reschedule tokens are issued, hashed and expired
# Architecture · zcal ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript on Node 22, one process, SQLite | a booking page is small and all of its difficulty is in the details | | Calendar read | Google Calendar freeBusy only, never events.list | freeBusy returns busy ranges without titles, so private events stay private | | Time | Instants stored in UTC, zones resolved from the IANA database at display | almost every scheduling bug is a timezone bug | | Booking | A local reservation row with a unique constraint, taken before the calendar is touched | two people clicking the same slot is the race that matters | | Calendar write | events.insert with a client-supplied base32hex id derived from the reservation | a retry after a timeout writes the same event rather than a second one | | Mail | One provider behind one send function, with a calendar invitation attached | the confirmation is as much the product as the page is | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Availability | freeBusy, buffers, notice and the horizon. | Any provider whose API returns busy ranges without contents. | | Zones | Day boundaries and dual-zone rendering. | Any date library with a current IANA database, provided both transition days stay tested. | | Booking | The unique reservation row and the race. | Not swappable for an application-level check, which loses the race under concurrency. | | Calendar write | The derived event id and timeout settlement. | Any provider, provided a timeout is settled by reading rather than writing again. | | Tokens | Hashed single-purpose cancel and reschedule links. | Any scheme, provided the raw token is never stored. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Reservations, event types, availability and the busy cache. - `GOOGLE_CLIENT_ID` · required · From the OAuth client. Must match the registered callback. - `GOOGLE_CLIENT_SECRET` · required, secret · From the OAuth client. Never in the repository. - `GOOGLE_REDIRECT_URI` · required · Registered on the client exactly, including the scheme and path. - `CALENDAR_ID` · required · The calendar queried for busy ranges and written to. - `PUBLIC_BASE_URL` · required · Used to build cancel and reschedule links in emails. - `SMTP_URL` · required, secret · Confirmations and invitations. Credentials only here. - `BUSY_CACHE_MAX_AGE_SECONDS` · required · Past this age with no successful refresh, the page stops offering slots. ## 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 · zcal product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript on Node 22, one process, SQLite, Google Calendar freeBusy only, never events.list, Instants stored in UTC, zones resolved from the IANA database at display, A local reservation row with a unique constraint, taken before the calendar is touched, events.insert with a client-supplied base32hex id derived from the reservation, One provider behind one send function, with a calendar invitation attached. - 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 - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. - Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing.
# Delivery milestones · zcal Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Busy, without reading anyone's calendar Availability is computed from busy ranges, and no event title ever enters your database. ### Steps 1. Use freeBusy and prove nothing else is stored The test is the point: a grep over the schema and the log output. Files: `src/calendar/freebusy.ts`, `tests/no-event-contents.test.ts` 2. Apply buffers and notice before offering a slot A slot you cannot actually take is worse than no slot. Files: `src/availability/compute.ts`, `tests/buffers.test.ts` 3. Batch past the calendar expansion ceiling Silent truncation shows you as free during a meeting. Files: `src/calendar/freebusy.ts` ### Done when - [ ] No event title or description is ever stored or logged - [ ] A private event blocks its slot - [ ] More than 50 calendars are batched, not truncated - [ ] Buffers, notice and horizon each change the offered slots ### Watch out - Computing availability from events.list is easier to read and quietly copies every meeting title, attendee list and description into your database and your logs, so a booking page becomes the least protected copy of your diary. Use freeBusy, which returns start and end pairs and nothing else, and assert in a test that no event summary field appears anywhere in storage. ## M2 · Two timezones and the two awkward days A slot shown to the invitee in their zone is the same instant you hold in yours. ### Steps 1. Compute day boundaries from zone rules per date A fixed offset is correct for about ten months a year. Files: `src/availability/zones.ts`, `tests/dst.test.ts` 2. Show both local times on every slot The commonest scheduling complaint is someone turning up an hour out. Files: `src/templates/book.html` 3. Store instants and derive every display One representation in the database, many on the screen. Files: `src/db/schema.sql` ### Done when - [ ] Both clock change days resolve by the stated rule - [ ] Two zones changing on different dates both render correctly - [ ] Stored instants and displayed times agree - [ ] Changing the displayed zone does not change the slot set ### Watch out - Generating slots by adding a fixed offset to midnight works until the clocks move, after which a nine o'clock slot is offered at eight for a fortnight, or the missing hour produces a slot that does not exist and the booking lands an hour out. Compute each day's boundaries from the zone rules for that date, and write a test for both transition days in both participants' zones. ## M3 · One slot, one booking Two people clicking the same slot produce one event and one clear refusal. ### Steps 1. Take the unique reservation row first Before the network. This is the actual lock. Files: `src/booking/reserve.ts`, `tests/race.test.ts` 2. Derive a valid base32hex event id A hyphenated hexadecimal id is rejected by the alphabet rule. Files: `src/calendar/event-id.ts`, `tests/event-id.test.ts` 3. Settle a timeout with a get, never a second insert The reference says a duplicate id may not be caught. Files: `src/calendar/reconcile.ts` ### Done when - [ ] Concurrent bookings of one slot produce one event - [ ] A timeout is settled by fetching the derived id - [ ] The derived id is valid base32hex of legal length - [ ] The losing booker sees a refreshed slot list ### Watch out - Relying on the calendar to reject a repeated event id is unsafe: the reference for events.insert states plainly that ID collisions cannot be guaranteed to be detected at event creation time, so the same id sent twice can produce two events. The local unique constraint is the real lock; the derived id is a second line that also makes a timeout recoverable by a get rather than a retry. ## M4 · Confirmation, reschedule and cancel Both sides get a correct confirmation and can change it without guessing a URL. ### Steps 1. Issue random single-purpose tokens, store hashes Sequential identifiers in a public URL are an invitation. Files: `src/tokens.ts`, `tests/token-guess.test.ts` 2. Make cancellation idempotent People double-click, and mail clients prefetch links. Files: `src/booking/cancel.ts` 3. Do reschedule in one transaction A half-done reschedule loses the meeting entirely. Files: `src/booking/reschedule.ts`, `tests/reschedule.test.ts` ### Done when - [ ] A guessed cancel link is refused - [ ] Raw tokens never reach the database or logs - [ ] Cancelling frees the slot and removes the event - [ ] A failed reschedule leaves the original intact ### Watch out - Putting the reservation id in the cancel URL means anyone who can count can cancel a stranger's meeting, and the ids are sequential because you used an integer primary key. Issue a random single-purpose token, store only its hash, give it an expiry, and make cancellation idempotent so a double click does not error. ## M5 · When the token dies and the calendar drifts (production only) A stale or broken connection stops offering slots instead of offering wrong ones. ### Steps 1. Gate the page on cache age and refresh health An unavailable page beats a wrong slot. Files: `src/calendar/auth.ts`, `tests/stale-cache.test.ts` 2. Reconcile both directions of drift Events vanish from calendars for reasons you will not see. Files: `src/calendar/reconcile.ts` 3. Rate limit the public page It is the one endpoint strangers can reach. Files: `src/middleware/ratelimit.ts` ### Done when - [ ] A revoked refresh token stops slots being offered - [ ] Drift is reported in both directions - [ ] The public page is rate limited and documented - [ ] Export round-trips into an empty database ### Watch out - Serving slots from a cache after the refresh has stopped working offers times you are already busy, and the first sign is someone arriving at a meeting you are not in. Treat cache age as a hard gate: past the stated age with no successful refresh, the page says booking is unavailable rather than guessing.
# Operations · zcal ## Backup The database holds the reservations and the tokens; the calendar holds the events. Back up the database daily, because a restore to a point before a booking leaves a calendar event with no reservation, which the reconciliation pass will then report. ## Restore Restore, then run reconciliation before reopening the page, so drift in both directions is reported before anyone books on top of it. Expect cancel tokens issued after the backup to be invalid, and say so in the email footer. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Refresh failures, the age of the busy cache, reservations without a confirmed calendar write, and the rate limiter's rejections. A silent refresh failure is the one that produces double bookings. ## Incident checklist A double booking means the unique constraint was not in force, and the reservations table will show two rows or one row with two calendar writes. A missing meeting means the calendar event was deleted elsewhere, which reconciliation reports and the confirmation email can prove. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No event title or description is confirmed stored or logged - [ ] Both clock change days are confirmed to resolve by the stated rule - [ ] Concurrent bookings of one slot are confirmed to produce one event - [ ] A guessed cancel link is confirmed refused - [ ] A revoked refresh token is confirmed to stop slots being offered ## Launch constraint Do not market omitted zcal 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. Reservations, event types, availability and the busy cache. DATABASE_PATH=./data/booking.db # Required. From the OAuth client. Must match the registered callback. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. From the OAuth client. Never in the repository. GOOGLE_CLIENT_SECRET=GOCSPX-...redacted # Required. Registered on the client exactly, including the scheme and path. GOOGLE_REDIRECT_URI=https://book.example.com/oauth/callback # Required. The calendar queried for busy ranges and written to. CALENDAR_ID=primary # Required. Used to build cancel and reschedule links in emails. PUBLIC_BASE_URL=https://book.example.com # Required · secret. Confirmations and invitations. Credentials only here. SMTP_URL=smtps://user:redacted@smtp.example.com:465 # Required. Past this age with no successful refresh, the page stops offering slots. BUSY_CACHE_MAX_AGE_SECONDS=900
$ choose a build depth, inspect the files, then open the complete pack in your agent
zcal: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xmulti-calendar conflict handling
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
xcalendar-provider edge cases and timezone correctness
Don't feel like building it? These folks already made it free.
all 3 free alternatives to zcal →· no votes, no pay-to-list · just what's real
zcal pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free forever | $0/user | $0/user | Unlimited scheduling links, meeting polls, and calendar connections. |
| pro | $7/user | — | 750 SMS credits/month; advanced scheduling and branding controls. |
| business | $12/user | — | 1,500 SMS credits/month; team administration and business controls. |
free tierUnlimited scheduling links, meeting polls, and calendar connections.
billingmonthly only; no annual price is published
hidden costsSeats added mid-cycle are prorated; SMS usage beyond included credits may require more credits, but the public overage price was not verified.
verified 2026-08-14 · source ↗
Vibecode zcal
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal zcal replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does zcal cost?
zcal'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 zcal?
Honestly: multi-calendar conflict handling; race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases; calendar-provider edge cases and timezone correctness. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to zcal?
Yes: Calendly Free (One active event type and unlimited bookings; meeting polls survive, workflows and teams do not.) TidyCal Free (Unlimited one-person booking types and paid bookings on one calendar; group meetings and fancy emails cost extra.) YouCanBookMe Free (One booking page, but it keeps forms, payments, confirmations, polls and the calendar-overlay trick.) All 3 curated free alternatives are at vibecodeit.com/zcal/alternatives. The prompt is for when you want it exactly your way.