Vibecode lemcal
track this build4 phases, 12 steps, beginner friendly0%The visible sales scheduling loop is buildable, but a credible replacement needs more than the first screen. lemcal earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of lemcal. 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 ===== # lemcal · indie build A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. 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 | One server at a stable public address with a persistent database | the OAuth redirect and the invitee both need an address that does not move | | Calendar | One provider through OAuth, refresh token encrypted at rest | this is the whole product and also the part that quietly stops working | | Storage | PostgreSQL with a unique constraint on event type and start instant | the promise is enforced by the database or it is not enforced | | Time | Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library | nine in the morning is two different moments in the week the clocks move | | Invitations | iCalendar attachments with a stable identifier and an incrementing sequence | a reschedule must replace the invitee's event, not add a second one | | Email | One transactional provider sending from an authenticated domain | a confirmation in the spam folder is a missed meeting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the OAuth flow and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint that prevents double booking lives here, so the database is part of the correctness argument. - Get it: Your package manager, or a small managed instance. - Verify: A partial unique index on event type and start instant is created and rejects a duplicate - [ ] **A Google Cloud project with an OAuth client and a decided publishing status** · free - Why: Testing status hands out refresh tokens that expire in seven days, which is the single most likely reason this build stops working. - Get it: Create the project, configure the consent screen, add the calendar scopes and note whether you left it in testing. - Verify: A refresh succeeds, and the publishing status is recorded in your notes - [ ] **A stable public HTTPS address** · a few pounds a month - Why: The OAuth redirect and the invitee's confirmation link both need an address that survives a restart. - Get it: Any small host with a domain, or a tunnel with a reserved name for development. - Verify: The redirect completes and a confirmation link opens from another device - [ ] **A transactional email provider key and an authenticated sending domain** · free at this volume with most providers - Why: Confirmations, reschedules and cancellations all arrive by email, and one in the spam folder is a missed meeting. - Get it: Any provider, then complete their domain authentication records. - Verify: A confirmation with an iCalendar attachment arrives in a real inbox and imports - [ ] **Your clock change rule, hold window and daily cap** · free - Why: All three are asserted in tests, and choosing them after seeing a failure means choosing whatever already happened. - Get it: Write down what a rule falling in the spring gap does, how long an unconfirmed hold lasts, and the maximum bookings per day per event type. - Verify: All three 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: - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for lemcal, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · lemcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that reads one calendar and cannot double-book you to replace lemcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two promises, and a booking tool that breaks either is worse than a shared spreadsheet. Never double-book. Two people on the same link clicking the same slot within the same second is not a rare case, it is what happens the moment the link is actually useful. The protection is a unique constraint on the event type and the start instant, so the second insert fails and that person is told the slot went, rather than an availability check that returned true for both. Never leak the calendar. What crosses from your calendar to a public page is busy and free and nothing else. Not a title, not an attendee, not a location, not a joining link, not a duration somebody could use to guess what the meeting is. A page that quietly reveals 'busy, interview, two hours' has done more damage than every double booking it prevented. ### Stack (fixed, do not substitute) - Runtime: One server at a stable public address with a persistent database · the OAuth redirect and the invitee both need an address that does not move - Calendar: One provider through OAuth, refresh token encrypted at rest · this is the whole product and also the part that quietly stops working - Storage: PostgreSQL with a unique constraint on event type and start instant · the promise is enforced by the database or it is not enforced - Time: Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library · nine in the morning is two different moments in the week the clocks move - Invitations: iCalendar attachments with a stable identifier and an incrementing sequence · a reschedule must replace the invitee's event, not add a second one - Email: One transactional provider sending from an authenticated domain · a confirmation in the spam folder is a missed meeting ### Data model (create this before Phase 1) - accounts(id, provider, calendar_id, refresh_token_enc, token_state, scopes, connected_at, last_sync_at, last_error) · token_state is what pauses the page - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, daily_cap, active) - availability_rules(event_type_id, weekday, start_local, end_local, owner_timezone) · written in local time, expanded per date - busy_blocks(account_id, starts_at, ends_at, fetched_at) · free and busy only, and there is no column for a title - bookings(id, event_type_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, state, remote_event_id, ics_uid, ics_sequence, created_at, cancelled_at) - unique(event_type_id, starts_at) where state is booked · one line of schema doing the work of an entire class of bug - holds(event_type_id, starts_at, session_token, expires_at) · a slot reserved while a form is being filled, expiring without a cleanup job - audit(at, actor, action, booking_id, detail) · who cancelled what, because the owner and the invitee will remember it differently ### Phase 1 · Connecting one calendar and noticing when it disconnects Build: Complete the OAuth flow for reading free and busy and writing events, store the refresh token encrypted, and refresh ahead of expiry. Treat a refresh failure as a state rather than an exception: the public page says bookings are paused and the owner is alerted once. Check your consent screen's publishing status before you rely on the connection lasting. Trap: A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. Done when: A refresh failure sets the token state to paused, the public page says so and offers no slots, tested by invalidating the token. An empty busy list from a failed fetch is never treated as availability, asserted in the suite. The refresh token is encrypted at rest and never appears in a log or an error page. The alert fires once per failure rather than once per request. Do not build yet: No availability rules, no booking, no email. ### Phase 2 · Availability that survives the clock change Build: Expand availability rules per date in the owner's zone, subtract busy blocks and buffers, apply minimum notice and the booking horizon, and render the result in the invitee's zone with the zone named in words. Decide and write down what happens to a rule that falls in the gap on the spring transition and on the repeated hour in autumn. Trap: Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. Done when: Slots on both clock change days match the documented rule, asserted against fixed dates. An invitee in another zone sees the same instant in their own zone with the zone named. Buffers, minimum notice and the horizon each narrow the offered set, tested separately. A busy block that partly overlaps a slot removes the slot rather than shortening it. Do not build yet: No booking, holds, email or invitations yet. ### Phase 3 · Booking exactly once Build: Insert the booking first, inside a transaction, and let the unique constraint decide. Only after it commits create the calendar event and store the remote identifier on the row. Hold a slot for a short window while the form is being completed, with the hold expiring by its own timestamp so correctness never depends on a cleanup job running. Trap: Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. Done when: Two concurrent requests for the same slot produce one booking and one taken response, tested with parallel calls. A failure creating the remote event leaves the booking in a retried state and never a silently confirmed one, tested by failing the provider call. An expired hold releases its slot with no background job running. Each booking records the remote event identifier once it exists. Do not build yet: No confirmations, reschedules or cancellations yet. ### Phase 4 · The calendar entry that updates instead of duplicating Build: Send confirmations carrying an iCalendar attachment with a stable identifier, sequence zero and a request method. A reschedule keeps the identifier, increments the sequence and sends a request; a cancellation keeps the identifier and sends a cancel. Put both parties' zones in the body text as well as in the attachment. Trap: Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. Done when: A reschedule imported into a calendar client replaces the original entry, verified by importing both files rather than by reading the code. A cancellation removes it. The sequence increments on every change and never repeats for one booking. The confirmation body states the time in both zones with the zone names spelled out. Do not build yet: No rate limiting, export or deletion yet. ### Phase 5 · A public form on the internet Build: Rate limit the booking endpoint per address and per event type, cap bookings per day, and require the invitee to confirm through an emailed link before the slot is held beyond the short window. Export every booking as CSV and iCalendar. Delete an invitee's personal fields on request while keeping the slot record, and write the deletion to the audit table. Never render invitee input as markup anywhere. Trap: A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. Done when: A flood from one address is rejected after the stated rate, tested. An unconfirmed booking releases its slot after the stated window. Export produces CSV and iCalendar that reimport correctly into a fresh instance and a calendar client. An invitee deletion removes personal fields, keeps the slot record and writes an audit row, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Team round robin and shared availability, which multiply every zone and conflict problem by the number of people and are most of what the paid product sells. - Taking payment at booking, which adds a refund path to every cancellation and a compliance surface this build does not want. - Several calendars across different providers, where each one's idea of busy differs and the reconciliation is a project of its own. - Text message reminders, which need a second provider, per-country rules and consent handling. ### README must contain - That the promise not to double-book is a unique constraint on event type and start instant, not a check - That only free and busy leaves the calendar, and that no column exists for an event title - That a refresh failure pauses the page, because an empty busy list would otherwise read as complete availability - That an OAuth consent screen left in testing publishing status issues short-lived refresh tokens, and what that does a week later - That invitations keep one identifier and increment a sequence, so a reschedule replaces rather than duplicates ===== AGENTS.md ===== # Agent instructions · lemcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server at a stable public address with a persistent database, One provider through OAuth, refresh token encrypted at rest, PostgreSQL with a unique constraint on event type and start instant, Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library, iCalendar attachments with a stable identifier and an incrementing sequence, One transactional provider sending from an authenticated domain. 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 - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ===== BUILD_PLAN.md ===== # Build plan · lemcal A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connecting one calendar and noticing when it disconnects The calendar stays connected, and a dead token pauses the page instead of emptying it. ### Steps 1. Complete the flow and encrypt the token At rest, with the key in the environment and never in the database. Files: `src/auth/oauth.ts`, `src/crypto/tokens.ts` 2. Check the consent screen's publishing status A testing project hands out tokens that die in a week. Files: `docs/oauth-setup.md` 3. Make a refresh failure a paused state An empty busy list must never mean wide open. Files: `src/calendar/sync.ts`, `tests/paused.test.ts` ### Done when - [ ] A dead token pauses the page and offers no slots - [ ] An empty busy list is never read as availability - [ ] The refresh token is encrypted and never logged - [ ] The alert fires once, not per request ### Watch out - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. ## Phase 2 · Availability that survives the clock change Slots come from the zone's rules for each date, never from a fixed offset. ### Steps 1. Expand rules per date, not per offset The zone's rules for that date are the only correct source. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 2. Subtract busy, buffers, notice and horizon in that order Each is a separate test, because each is separately wrong. Files: `src/availability/filters.ts` 3. Name the zone everywhere a time is shown Two o'clock without a zone is a meeting somebody misses. Files: `src/ui/slots.tsx` ### Done when - [ ] Both clock change days match the documented rule - [ ] Times render in the invitee's zone, named - [ ] Buffers, notice and horizon each narrow the set - [ ] A partial overlap removes the whole slot ### Watch out - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. ## Phase 3 · Booking exactly once Two people clicking the same slot produce one booking and one clear message. ### Steps 1. Insert first and let the constraint decide A check before a write is a race with a friendly name. Files: `src/booking/create.ts`, `tests/concurrent.test.ts` 2. Create the remote event after the commit And record its identifier, so a retry does not make a second one. Files: `src/calendar/events.ts` 3. Expire holds by timestamp Correctness should not depend on a cron job being alive. Files: `src/booking/holds.ts` ### Done when - [ ] Concurrent bookings yield one booking - [ ] A failed remote create is retried, not confirmed - [ ] Expired holds free their slot without a job - [ ] The remote identifier is stored on the row ### Watch out - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. ## Phase 4 · The calendar entry that updates instead of duplicating A reschedule replaces the invitee's event rather than adding a second one. ### Steps 1. Keep one identifier for the life of a booking A new one means a second entry in their calendar. Files: `src/ics/build.ts`, `tests/ics.test.ts` 2. Increment the sequence on every change Clients use it to decide whether your update wins. Files: `src/ics/build.ts` 3. Import both files into a real client The only test that proves a reschedule replaced anything. Files: `docs/ics-verification.md` ### Done when - [ ] A reschedule replaces the original entry - [ ] A cancellation removes it - [ ] The sequence increments and never repeats - [ ] Both zones appear in the body text ### Watch out - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ## Not in this build - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. ## After v1, if you want it - A second event type with its own buffers and cap, which the schema already carries - Reminder emails at a fixed lead time, once the send path has a month without a duplicate - A read-only availability feed for a colleague, which needs no new calendar permissions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds and the audit trail. DATABASE_URL=postgres://localhost:5432/booking # Required. Used in the OAuth redirect and in every confirmation link. PUBLIC_BASE_URL=https://book.example.com # Required. From the OAuth client you created. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. Never rendered on an error page, which is where secrets usually escape. GOOGLE_CLIENT_SECRET=sk-...redacted # Required · secret. Encrypts the refresh token at rest. Losing it means reconnecting the calendar. TOKEN_ENCRYPTION_KEY=sk-...redacted # Required. An IANA name. Availability rules are written and expanded in it. OWNER_TIMEZONE=Europe/London # Required · secret. Confirmations, reschedules and cancellations. EMAIL_API_KEY=sk-...redacted # Required. How long an unconfirmed slot is held. Expires by timestamp, not by a job. HOLD_MINUTES=10
You are building a lean indie version of lemcal. 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 ===== # lemcal · indie build A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. 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 | One server at a stable public address with a persistent database | the OAuth redirect and the invitee both need an address that does not move | | Calendar | One provider through OAuth, refresh token encrypted at rest | this is the whole product and also the part that quietly stops working | | Storage | PostgreSQL with a unique constraint on event type and start instant | the promise is enforced by the database or it is not enforced | | Time | Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library | nine in the morning is two different moments in the week the clocks move | | Invitations | iCalendar attachments with a stable identifier and an incrementing sequence | a reschedule must replace the invitee's event, not add a second one | | Email | One transactional provider sending from an authenticated domain | a confirmation in the spam folder is a missed meeting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the OAuth flow and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint that prevents double booking lives here, so the database is part of the correctness argument. - Get it: Your package manager, or a small managed instance. - Verify: A partial unique index on event type and start instant is created and rejects a duplicate - [ ] **A Google Cloud project with an OAuth client and a decided publishing status** · free - Why: Testing status hands out refresh tokens that expire in seven days, which is the single most likely reason this build stops working. - Get it: Create the project, configure the consent screen, add the calendar scopes and note whether you left it in testing. - Verify: A refresh succeeds, and the publishing status is recorded in your notes - [ ] **A stable public HTTPS address** · a few pounds a month - Why: The OAuth redirect and the invitee's confirmation link both need an address that survives a restart. - Get it: Any small host with a domain, or a tunnel with a reserved name for development. - Verify: The redirect completes and a confirmation link opens from another device - [ ] **A transactional email provider key and an authenticated sending domain** · free at this volume with most providers - Why: Confirmations, reschedules and cancellations all arrive by email, and one in the spam folder is a missed meeting. - Get it: Any provider, then complete their domain authentication records. - Verify: A confirmation with an iCalendar attachment arrives in a real inbox and imports - [ ] **Your clock change rule, hold window and daily cap** · free - Why: All three are asserted in tests, and choosing them after seeing a failure means choosing whatever already happened. - Get it: Write down what a rule falling in the spring gap does, how long an unconfirmed hold lasts, and the maximum bookings per day per event type. - Verify: All three 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: - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for lemcal, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · lemcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that reads one calendar and cannot double-book you to replace lemcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two promises, and a booking tool that breaks either is worse than a shared spreadsheet. Never double-book. Two people on the same link clicking the same slot within the same second is not a rare case, it is what happens the moment the link is actually useful. The protection is a unique constraint on the event type and the start instant, so the second insert fails and that person is told the slot went, rather than an availability check that returned true for both. Never leak the calendar. What crosses from your calendar to a public page is busy and free and nothing else. Not a title, not an attendee, not a location, not a joining link, not a duration somebody could use to guess what the meeting is. A page that quietly reveals 'busy, interview, two hours' has done more damage than every double booking it prevented. ### Stack (fixed, do not substitute) - Runtime: One server at a stable public address with a persistent database · the OAuth redirect and the invitee both need an address that does not move - Calendar: One provider through OAuth, refresh token encrypted at rest · this is the whole product and also the part that quietly stops working - Storage: PostgreSQL with a unique constraint on event type and start instant · the promise is enforced by the database or it is not enforced - Time: Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library · nine in the morning is two different moments in the week the clocks move - Invitations: iCalendar attachments with a stable identifier and an incrementing sequence · a reschedule must replace the invitee's event, not add a second one - Email: One transactional provider sending from an authenticated domain · a confirmation in the spam folder is a missed meeting ### Data model (create this before Phase 1) - accounts(id, provider, calendar_id, refresh_token_enc, token_state, scopes, connected_at, last_sync_at, last_error) · token_state is what pauses the page - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, daily_cap, active) - availability_rules(event_type_id, weekday, start_local, end_local, owner_timezone) · written in local time, expanded per date - busy_blocks(account_id, starts_at, ends_at, fetched_at) · free and busy only, and there is no column for a title - bookings(id, event_type_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, state, remote_event_id, ics_uid, ics_sequence, created_at, cancelled_at) - unique(event_type_id, starts_at) where state is booked · one line of schema doing the work of an entire class of bug - holds(event_type_id, starts_at, session_token, expires_at) · a slot reserved while a form is being filled, expiring without a cleanup job - audit(at, actor, action, booking_id, detail) · who cancelled what, because the owner and the invitee will remember it differently ### Phase 1 · Connecting one calendar and noticing when it disconnects Build: Complete the OAuth flow for reading free and busy and writing events, store the refresh token encrypted, and refresh ahead of expiry. Treat a refresh failure as a state rather than an exception: the public page says bookings are paused and the owner is alerted once. Check your consent screen's publishing status before you rely on the connection lasting. Trap: A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. Done when: A refresh failure sets the token state to paused, the public page says so and offers no slots, tested by invalidating the token. An empty busy list from a failed fetch is never treated as availability, asserted in the suite. The refresh token is encrypted at rest and never appears in a log or an error page. The alert fires once per failure rather than once per request. Do not build yet: No availability rules, no booking, no email. ### Phase 2 · Availability that survives the clock change Build: Expand availability rules per date in the owner's zone, subtract busy blocks and buffers, apply minimum notice and the booking horizon, and render the result in the invitee's zone with the zone named in words. Decide and write down what happens to a rule that falls in the gap on the spring transition and on the repeated hour in autumn. Trap: Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. Done when: Slots on both clock change days match the documented rule, asserted against fixed dates. An invitee in another zone sees the same instant in their own zone with the zone named. Buffers, minimum notice and the horizon each narrow the offered set, tested separately. A busy block that partly overlaps a slot removes the slot rather than shortening it. Do not build yet: No booking, holds, email or invitations yet. ### Phase 3 · Booking exactly once Build: Insert the booking first, inside a transaction, and let the unique constraint decide. Only after it commits create the calendar event and store the remote identifier on the row. Hold a slot for a short window while the form is being completed, with the hold expiring by its own timestamp so correctness never depends on a cleanup job running. Trap: Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. Done when: Two concurrent requests for the same slot produce one booking and one taken response, tested with parallel calls. A failure creating the remote event leaves the booking in a retried state and never a silently confirmed one, tested by failing the provider call. An expired hold releases its slot with no background job running. Each booking records the remote event identifier once it exists. Do not build yet: No confirmations, reschedules or cancellations yet. ### Phase 4 · The calendar entry that updates instead of duplicating Build: Send confirmations carrying an iCalendar attachment with a stable identifier, sequence zero and a request method. A reschedule keeps the identifier, increments the sequence and sends a request; a cancellation keeps the identifier and sends a cancel. Put both parties' zones in the body text as well as in the attachment. Trap: Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. Done when: A reschedule imported into a calendar client replaces the original entry, verified by importing both files rather than by reading the code. A cancellation removes it. The sequence increments on every change and never repeats for one booking. The confirmation body states the time in both zones with the zone names spelled out. Do not build yet: No rate limiting, export or deletion yet. ### Phase 5 · A public form on the internet Build: Rate limit the booking endpoint per address and per event type, cap bookings per day, and require the invitee to confirm through an emailed link before the slot is held beyond the short window. Export every booking as CSV and iCalendar. Delete an invitee's personal fields on request while keeping the slot record, and write the deletion to the audit table. Never render invitee input as markup anywhere. Trap: A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. Done when: A flood from one address is rejected after the stated rate, tested. An unconfirmed booking releases its slot after the stated window. Export produces CSV and iCalendar that reimport correctly into a fresh instance and a calendar client. An invitee deletion removes personal fields, keeps the slot record and writes an audit row, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Team round robin and shared availability, which multiply every zone and conflict problem by the number of people and are most of what the paid product sells. - Taking payment at booking, which adds a refund path to every cancellation and a compliance surface this build does not want. - Several calendars across different providers, where each one's idea of busy differs and the reconciliation is a project of its own. - Text message reminders, which need a second provider, per-country rules and consent handling. ### README must contain - That the promise not to double-book is a unique constraint on event type and start instant, not a check - That only free and busy leaves the calendar, and that no column exists for an event title - That a refresh failure pauses the page, because an empty busy list would otherwise read as complete availability - That an OAuth consent screen left in testing publishing status issues short-lived refresh tokens, and what that does a week later - That invitations keep one identifier and increment a sequence, so a reschedule replaces rather than duplicates ===== AGENTS.md ===== # Agent instructions · lemcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server at a stable public address with a persistent database, One provider through OAuth, refresh token encrypted at rest, PostgreSQL with a unique constraint on event type and start instant, Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library, iCalendar attachments with a stable identifier and an incrementing sequence, One transactional provider sending from an authenticated domain. 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 - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ===== BUILD_PLAN.md ===== # Build plan · lemcal A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connecting one calendar and noticing when it disconnects The calendar stays connected, and a dead token pauses the page instead of emptying it. ### Steps 1. Complete the flow and encrypt the token At rest, with the key in the environment and never in the database. Files: `src/auth/oauth.ts`, `src/crypto/tokens.ts` 2. Check the consent screen's publishing status A testing project hands out tokens that die in a week. Files: `docs/oauth-setup.md` 3. Make a refresh failure a paused state An empty busy list must never mean wide open. Files: `src/calendar/sync.ts`, `tests/paused.test.ts` ### Done when - [ ] A dead token pauses the page and offers no slots - [ ] An empty busy list is never read as availability - [ ] The refresh token is encrypted and never logged - [ ] The alert fires once, not per request ### Watch out - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. ## Phase 2 · Availability that survives the clock change Slots come from the zone's rules for each date, never from a fixed offset. ### Steps 1. Expand rules per date, not per offset The zone's rules for that date are the only correct source. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 2. Subtract busy, buffers, notice and horizon in that order Each is a separate test, because each is separately wrong. Files: `src/availability/filters.ts` 3. Name the zone everywhere a time is shown Two o'clock without a zone is a meeting somebody misses. Files: `src/ui/slots.tsx` ### Done when - [ ] Both clock change days match the documented rule - [ ] Times render in the invitee's zone, named - [ ] Buffers, notice and horizon each narrow the set - [ ] A partial overlap removes the whole slot ### Watch out - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. ## Phase 3 · Booking exactly once Two people clicking the same slot produce one booking and one clear message. ### Steps 1. Insert first and let the constraint decide A check before a write is a race with a friendly name. Files: `src/booking/create.ts`, `tests/concurrent.test.ts` 2. Create the remote event after the commit And record its identifier, so a retry does not make a second one. Files: `src/calendar/events.ts` 3. Expire holds by timestamp Correctness should not depend on a cron job being alive. Files: `src/booking/holds.ts` ### Done when - [ ] Concurrent bookings yield one booking - [ ] A failed remote create is retried, not confirmed - [ ] Expired holds free their slot without a job - [ ] The remote identifier is stored on the row ### Watch out - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. ## Phase 4 · The calendar entry that updates instead of duplicating A reschedule replaces the invitee's event rather than adding a second one. ### Steps 1. Keep one identifier for the life of a booking A new one means a second entry in their calendar. Files: `src/ics/build.ts`, `tests/ics.test.ts` 2. Increment the sequence on every change Clients use it to decide whether your update wins. Files: `src/ics/build.ts` 3. Import both files into a real client The only test that proves a reschedule replaced anything. Files: `docs/ics-verification.md` ### Done when - [ ] A reschedule replaces the original entry - [ ] A cancellation removes it - [ ] The sequence increments and never repeats - [ ] Both zones appear in the body text ### Watch out - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ## Not in this build - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. ## After v1, if you want it - A second event type with its own buffers and cap, which the schema already carries - Reminder emails at a fixed lead time, once the send path has a month without a duplicate - A read-only availability feed for a colleague, which needs no new calendar permissions ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds and the audit trail. DATABASE_URL=postgres://localhost:5432/booking # Required. Used in the OAuth redirect and in every confirmation link. PUBLIC_BASE_URL=https://book.example.com # Required. From the OAuth client you created. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. Never rendered on an error page, which is where secrets usually escape. GOOGLE_CLIENT_SECRET=sk-...redacted # Required · secret. Encrypts the refresh token at rest. Losing it means reconnecting the calendar. TOKEN_ENCRYPTION_KEY=sk-...redacted # Required. An IANA name. Availability rules are written and expanded in it. OWNER_TIMEZONE=Europe/London # Required · secret. Confirmations, reschedules and cancellations. EMAIL_API_KEY=sk-...redacted # Required. How long an unconfirmed slot is held. Expires by timestamp, not by a job. HOLD_MINUTES=10
You are building a production product version of lemcal. 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 ===== # lemcal · product brief ## Problem The visible sales scheduling loop is buildable, but a credible replacement needs more than the first screen. lemcal earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that offers only real availability in the invitee's own zone, cannot be made to double-book by two people clicking at once, reveals nothing about your calendar beyond busy and free, and says it is paused rather than quietly offering every hour of your week. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - A dead token is confirmed to pause the page rather than open it - Both clock change days are confirmed to match the documented rule - Concurrent bookings are confirmed to yield exactly one booking - A reschedule is confirmed to replace the entry in a real calendar client - No calendar field beyond busy and free is confirmed to reach the public page ===== BRIEF.md ===== # Build brief · lemcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that reads one calendar and cannot double-book you to replace lemcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two promises, and a booking tool that breaks either is worse than a shared spreadsheet. Never double-book. Two people on the same link clicking the same slot within the same second is not a rare case, it is what happens the moment the link is actually useful. The protection is a unique constraint on the event type and the start instant, so the second insert fails and that person is told the slot went, rather than an availability check that returned true for both. Never leak the calendar. What crosses from your calendar to a public page is busy and free and nothing else. Not a title, not an attendee, not a location, not a joining link, not a duration somebody could use to guess what the meeting is. A page that quietly reveals 'busy, interview, two hours' has done more damage than every double booking it prevented. ### Stack (fixed, do not substitute) - Runtime: One server at a stable public address with a persistent database · the OAuth redirect and the invitee both need an address that does not move - Calendar: One provider through OAuth, refresh token encrypted at rest · this is the whole product and also the part that quietly stops working - Storage: PostgreSQL with a unique constraint on event type and start instant · the promise is enforced by the database or it is not enforced - Time: Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library · nine in the morning is two different moments in the week the clocks move - Invitations: iCalendar attachments with a stable identifier and an incrementing sequence · a reschedule must replace the invitee's event, not add a second one - Email: One transactional provider sending from an authenticated domain · a confirmation in the spam folder is a missed meeting ### Data model (create this before Phase 1) - accounts(id, provider, calendar_id, refresh_token_enc, token_state, scopes, connected_at, last_sync_at, last_error) · token_state is what pauses the page - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, daily_cap, active) - availability_rules(event_type_id, weekday, start_local, end_local, owner_timezone) · written in local time, expanded per date - busy_blocks(account_id, starts_at, ends_at, fetched_at) · free and busy only, and there is no column for a title - bookings(id, event_type_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, state, remote_event_id, ics_uid, ics_sequence, created_at, cancelled_at) - unique(event_type_id, starts_at) where state is booked · one line of schema doing the work of an entire class of bug - holds(event_type_id, starts_at, session_token, expires_at) · a slot reserved while a form is being filled, expiring without a cleanup job - audit(at, actor, action, booking_id, detail) · who cancelled what, because the owner and the invitee will remember it differently ### Phase 1 · Connecting one calendar and noticing when it disconnects Build: Complete the OAuth flow for reading free and busy and writing events, store the refresh token encrypted, and refresh ahead of expiry. Treat a refresh failure as a state rather than an exception: the public page says bookings are paused and the owner is alerted once. Check your consent screen's publishing status before you rely on the connection lasting. Trap: A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. Done when: A refresh failure sets the token state to paused, the public page says so and offers no slots, tested by invalidating the token. An empty busy list from a failed fetch is never treated as availability, asserted in the suite. The refresh token is encrypted at rest and never appears in a log or an error page. The alert fires once per failure rather than once per request. Do not build yet: No availability rules, no booking, no email. ### Phase 2 · Availability that survives the clock change Build: Expand availability rules per date in the owner's zone, subtract busy blocks and buffers, apply minimum notice and the booking horizon, and render the result in the invitee's zone with the zone named in words. Decide and write down what happens to a rule that falls in the gap on the spring transition and on the repeated hour in autumn. Trap: Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. Done when: Slots on both clock change days match the documented rule, asserted against fixed dates. An invitee in another zone sees the same instant in their own zone with the zone named. Buffers, minimum notice and the horizon each narrow the offered set, tested separately. A busy block that partly overlaps a slot removes the slot rather than shortening it. Do not build yet: No booking, holds, email or invitations yet. ### Phase 3 · Booking exactly once Build: Insert the booking first, inside a transaction, and let the unique constraint decide. Only after it commits create the calendar event and store the remote identifier on the row. Hold a slot for a short window while the form is being completed, with the hold expiring by its own timestamp so correctness never depends on a cleanup job running. Trap: Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. Done when: Two concurrent requests for the same slot produce one booking and one taken response, tested with parallel calls. A failure creating the remote event leaves the booking in a retried state and never a silently confirmed one, tested by failing the provider call. An expired hold releases its slot with no background job running. Each booking records the remote event identifier once it exists. Do not build yet: No confirmations, reschedules or cancellations yet. ### Phase 4 · The calendar entry that updates instead of duplicating Build: Send confirmations carrying an iCalendar attachment with a stable identifier, sequence zero and a request method. A reschedule keeps the identifier, increments the sequence and sends a request; a cancellation keeps the identifier and sends a cancel. Put both parties' zones in the body text as well as in the attachment. Trap: Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. Done when: A reschedule imported into a calendar client replaces the original entry, verified by importing both files rather than by reading the code. A cancellation removes it. The sequence increments on every change and never repeats for one booking. The confirmation body states the time in both zones with the zone names spelled out. Do not build yet: No rate limiting, export or deletion yet. ### Phase 5 · A public form on the internet Build: Rate limit the booking endpoint per address and per event type, cap bookings per day, and require the invitee to confirm through an emailed link before the slot is held beyond the short window. Export every booking as CSV and iCalendar. Delete an invitee's personal fields on request while keeping the slot record, and write the deletion to the audit table. Never render invitee input as markup anywhere. Trap: A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. Done when: A flood from one address is rejected after the stated rate, tested. An unconfirmed booking releases its slot after the stated window. Export produces CSV and iCalendar that reimport correctly into a fresh instance and a calendar client. An invitee deletion removes personal fields, keeps the slot record and writes an audit row, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Team round robin and shared availability, which multiply every zone and conflict problem by the number of people and are most of what the paid product sells. - Taking payment at booking, which adds a refund path to every cancellation and a compliance surface this build does not want. - Several calendars across different providers, where each one's idea of busy differs and the reconciliation is a project of its own. - Text message reminders, which need a second provider, per-country rules and consent handling. ### README must contain - That the promise not to double-book is a unique constraint on event type and start instant, not a check - That only free and busy leaves the calendar, and that no column exists for an event title - That a refresh failure pauses the page, because an empty busy list would otherwise read as complete availability - That an OAuth consent screen left in testing publishing status issues short-lived refresh tokens, and what that does a week later - That invitations keep one identifier and increment a sequence, so a reschedule replaces rather than duplicates ===== ARCHITECTURE.md ===== # Architecture · lemcal ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server at a stable public address with a persistent database | the OAuth redirect and the invitee both need an address that does not move | | Calendar | One provider through OAuth, refresh token encrypted at rest | this is the whole product and also the part that quietly stops working | | Storage | PostgreSQL with a unique constraint on event type and start instant | the promise is enforced by the database or it is not enforced | | Time | Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library | nine in the morning is two different moments in the week the clocks move | | Invitations | iCalendar attachments with a stable identifier and an incrementing sequence | a reschedule must replace the invitee's event, not add a second one | | Email | One transactional provider sending from an authenticated domain | a confirmation in the spam folder is a missed meeting | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Connection | OAuth, refresh, encryption and the paused state. | Any provider, provided a failed fetch never becomes an empty busy list. | | Availability | Per-date expansion, buffers, notice and horizon. | Any implementation, provided both clock change days stay tested. | | Booking | The constraint, holds and remote event creation. | Not swappable for check-then-insert, which is the race this build exists to avoid. | | Invitations | Stable identifiers, sequences and cancellations. | Any generator, provided the identifier survives a reschedule. | | Public page | Rate limits, confirmation, export and erasure. | Any front end, provided no calendar field beyond busy and free reaches it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required · Bookings, holds and the audit trail. - `PUBLIC_BASE_URL` · required · Used in the OAuth redirect and in every confirmation link. - `GOOGLE_CLIENT_ID` · required · From the OAuth client you created. - `GOOGLE_CLIENT_SECRET` · required, secret · Never rendered on an error page, which is where secrets usually escape. - `TOKEN_ENCRYPTION_KEY` · required, secret · Encrypts the refresh token at rest. Losing it means reconnecting the calendar. - `OWNER_TIMEZONE` · required · An IANA name. Availability rules are written and expanded in it. - `EMAIL_API_KEY` · required, secret · Confirmations, reschedules and cancellations. - `HOLD_MINUTES` · required · How long an unconfirmed slot is held. Expires by timestamp, not by a job. ## 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 · lemcal product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One server at a stable public address with a persistent database, One provider through OAuth, refresh token encrypted at rest, PostgreSQL with a unique constraint on event type and start instant, Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library, iCalendar attachments with a stable identifier and an incrementing sequence, One transactional provider sending from an authenticated domain. - 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 - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. - A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. ===== MILESTONES.md ===== # Delivery milestones · lemcal Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Connecting one calendar and noticing when it disconnects The calendar stays connected, and a dead token pauses the page instead of emptying it. ### Steps 1. Complete the flow and encrypt the token At rest, with the key in the environment and never in the database. Files: `src/auth/oauth.ts`, `src/crypto/tokens.ts` 2. Check the consent screen's publishing status A testing project hands out tokens that die in a week. Files: `docs/oauth-setup.md` 3. Make a refresh failure a paused state An empty busy list must never mean wide open. Files: `src/calendar/sync.ts`, `tests/paused.test.ts` ### Done when - [ ] A dead token pauses the page and offers no slots - [ ] An empty busy list is never read as availability - [ ] The refresh token is encrypted and never logged - [ ] The alert fires once, not per request ### Watch out - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. ## M2 · Availability that survives the clock change Slots come from the zone's rules for each date, never from a fixed offset. ### Steps 1. Expand rules per date, not per offset The zone's rules for that date are the only correct source. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 2. Subtract busy, buffers, notice and horizon in that order Each is a separate test, because each is separately wrong. Files: `src/availability/filters.ts` 3. Name the zone everywhere a time is shown Two o'clock without a zone is a meeting somebody misses. Files: `src/ui/slots.tsx` ### Done when - [ ] Both clock change days match the documented rule - [ ] Times render in the invitee's zone, named - [ ] Buffers, notice and horizon each narrow the set - [ ] A partial overlap removes the whole slot ### Watch out - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. ## M3 · Booking exactly once Two people clicking the same slot produce one booking and one clear message. ### Steps 1. Insert first and let the constraint decide A check before a write is a race with a friendly name. Files: `src/booking/create.ts`, `tests/concurrent.test.ts` 2. Create the remote event after the commit And record its identifier, so a retry does not make a second one. Files: `src/calendar/events.ts` 3. Expire holds by timestamp Correctness should not depend on a cron job being alive. Files: `src/booking/holds.ts` ### Done when - [ ] Concurrent bookings yield one booking - [ ] A failed remote create is retried, not confirmed - [ ] Expired holds free their slot without a job - [ ] The remote identifier is stored on the row ### Watch out - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. ## M4 · The calendar entry that updates instead of duplicating A reschedule replaces the invitee's event rather than adding a second one. ### Steps 1. Keep one identifier for the life of a booking A new one means a second entry in their calendar. Files: `src/ics/build.ts`, `tests/ics.test.ts` 2. Increment the sequence on every change Clients use it to decide whether your update wins. Files: `src/ics/build.ts` 3. Import both files into a real client The only test that proves a reschedule replaced anything. Files: `docs/ics-verification.md` ### Done when - [ ] A reschedule replaces the original entry - [ ] A cancellation removes it - [ ] The sequence increments and never repeats - [ ] Both zones appear in the body text ### Watch out - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ## M5 · A public form on the internet (production only) The page survives strangers, and the owner can leave with everything. ### Steps 1. Confirm by email before holding the slot The cheapest filter against scripted bookings. Files: `src/booking/confirm.ts`, `tests/abuse.test.ts` 2. Rate limit and cap per day Per address and per event type, with a readable refusal. Files: `src/middleware/ratelimit.ts` 3. Export and delete, both tested Leaving should be as easy as arriving. Files: `src/export/bookings.ts`, `src/privacy/erase.ts` ### Done when - [ ] A flood from one address is rejected - [ ] Unconfirmed bookings release their slot - [ ] Export reimports into a client correctly - [ ] Deletion clears personal fields and audits it ### Watch out - A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. ===== OPERATIONS.md ===== # Operations · lemcal ## Backup The bookings table and the audit trail. The calendar itself is backed up by the provider, but your record of who booked what and who cancelled it exists only here, and it is the thing you will need when a client says they never cancelled. ## Restore Restore, then reconnect the calendar, because the encrypted refresh token may predate a key rotation. Confirm the paused state clears and that a test booking still lands in the real calendar before sharing the link again. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Time since the last successful busy fetch, count of paused states, rejected duplicate inserts and unconfirmed holds expiring. A rise in the last two usually means the link has been shared somewhere public. ## Incident checklist A double booking means the constraint is missing or the insert path bypassed it, and the audit table shows both rows. A quiet page nearly always means an expired refresh token, which is why the paused banner exists rather than an empty grid. 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 - [ ] A dead token is confirmed to pause the page rather than open it - [ ] Both clock change days are confirmed to match the documented rule - [ ] Concurrent bookings are confirmed to yield exactly one booking - [ ] A reschedule is confirmed to replace the entry in a real calendar client - [ ] No calendar field beyond busy and free is confirmed to reach the public page ## Launch constraint Do not market omitted lemcal 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. Bookings, holds and the audit trail. DATABASE_URL=postgres://localhost:5432/booking # Required. Used in the OAuth redirect and in every confirmation link. PUBLIC_BASE_URL=https://book.example.com # Required. From the OAuth client you created. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. Never rendered on an error page, which is where secrets usually escape. GOOGLE_CLIENT_SECRET=sk-...redacted # Required · secret. Encrypts the refresh token at rest. Losing it means reconnecting the calendar. TOKEN_ENCRYPTION_KEY=sk-...redacted # Required. An IANA name. Availability rules are written and expanded in it. OWNER_TIMEZONE=Europe/London # Required · secret. Confirmations, reschedules and cancellations. EMAIL_API_KEY=sk-...redacted # Required. How long an unconfirmed slot is held. Expires by timestamp, not by a job. HOLD_MINUTES=10
# lemcal · indie build A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. 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 | One server at a stable public address with a persistent database | the OAuth redirect and the invitee both need an address that does not move | | Calendar | One provider through OAuth, refresh token encrypted at rest | this is the whole product and also the part that quietly stops working | | Storage | PostgreSQL with a unique constraint on event type and start instant | the promise is enforced by the database or it is not enforced | | Time | Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library | nine in the morning is two different moments in the week the clocks move | | Invitations | iCalendar attachments with a stable identifier and an incrementing sequence | a reschedule must replace the invitee's event, not add a second one | | Email | One transactional provider sending from an authenticated domain | a confirmation in the spam folder is a missed meeting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server, the OAuth flow and the tests. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally, a few pounds a month managed - Why: The unique constraint that prevents double booking lives here, so the database is part of the correctness argument. - Get it: Your package manager, or a small managed instance. - Verify: A partial unique index on event type and start instant is created and rejects a duplicate - [ ] **A Google Cloud project with an OAuth client and a decided publishing status** · free - Why: Testing status hands out refresh tokens that expire in seven days, which is the single most likely reason this build stops working. - Get it: Create the project, configure the consent screen, add the calendar scopes and note whether you left it in testing. - Verify: A refresh succeeds, and the publishing status is recorded in your notes - [ ] **A stable public HTTPS address** · a few pounds a month - Why: The OAuth redirect and the invitee's confirmation link both need an address that survives a restart. - Get it: Any small host with a domain, or a tunnel with a reserved name for development. - Verify: The redirect completes and a confirmation link opens from another device - [ ] **A transactional email provider key and an authenticated sending domain** · free at this volume with most providers - Why: Confirmations, reschedules and cancellations all arrive by email, and one in the spam folder is a missed meeting. - Get it: Any provider, then complete their domain authentication records. - Verify: A confirmation with an iCalendar attachment arrives in a real inbox and imports - [ ] **Your clock change rule, hold window and daily cap** · free - Why: All three are asserted in tests, and choosing them after seeing a failure means choosing whatever already happened. - Get it: Write down what a rule falling in the spring gap does, how long an unconfirmed hold lasts, and the maximum bookings per day per event type. - Verify: All three 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: - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling If one of those is essential to you, that is the reason to keep paying for lemcal, and the README should say so rather than pretend.
# Build brief · lemcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that reads one calendar and cannot double-book you to replace lemcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two promises, and a booking tool that breaks either is worse than a shared spreadsheet. Never double-book. Two people on the same link clicking the same slot within the same second is not a rare case, it is what happens the moment the link is actually useful. The protection is a unique constraint on the event type and the start instant, so the second insert fails and that person is told the slot went, rather than an availability check that returned true for both. Never leak the calendar. What crosses from your calendar to a public page is busy and free and nothing else. Not a title, not an attendee, not a location, not a joining link, not a duration somebody could use to guess what the meeting is. A page that quietly reveals 'busy, interview, two hours' has done more damage than every double booking it prevented. ### Stack (fixed, do not substitute) - Runtime: One server at a stable public address with a persistent database · the OAuth redirect and the invitee both need an address that does not move - Calendar: One provider through OAuth, refresh token encrypted at rest · this is the whole product and also the part that quietly stops working - Storage: PostgreSQL with a unique constraint on event type and start instant · the promise is enforced by the database or it is not enforced - Time: Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library · nine in the morning is two different moments in the week the clocks move - Invitations: iCalendar attachments with a stable identifier and an incrementing sequence · a reschedule must replace the invitee's event, not add a second one - Email: One transactional provider sending from an authenticated domain · a confirmation in the spam folder is a missed meeting ### Data model (create this before Phase 1) - accounts(id, provider, calendar_id, refresh_token_enc, token_state, scopes, connected_at, last_sync_at, last_error) · token_state is what pauses the page - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, daily_cap, active) - availability_rules(event_type_id, weekday, start_local, end_local, owner_timezone) · written in local time, expanded per date - busy_blocks(account_id, starts_at, ends_at, fetched_at) · free and busy only, and there is no column for a title - bookings(id, event_type_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, state, remote_event_id, ics_uid, ics_sequence, created_at, cancelled_at) - unique(event_type_id, starts_at) where state is booked · one line of schema doing the work of an entire class of bug - holds(event_type_id, starts_at, session_token, expires_at) · a slot reserved while a form is being filled, expiring without a cleanup job - audit(at, actor, action, booking_id, detail) · who cancelled what, because the owner and the invitee will remember it differently ### Phase 1 · Connecting one calendar and noticing when it disconnects Build: Complete the OAuth flow for reading free and busy and writing events, store the refresh token encrypted, and refresh ahead of expiry. Treat a refresh failure as a state rather than an exception: the public page says bookings are paused and the owner is alerted once. Check your consent screen's publishing status before you rely on the connection lasting. Trap: A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. Done when: A refresh failure sets the token state to paused, the public page says so and offers no slots, tested by invalidating the token. An empty busy list from a failed fetch is never treated as availability, asserted in the suite. The refresh token is encrypted at rest and never appears in a log or an error page. The alert fires once per failure rather than once per request. Do not build yet: No availability rules, no booking, no email. ### Phase 2 · Availability that survives the clock change Build: Expand availability rules per date in the owner's zone, subtract busy blocks and buffers, apply minimum notice and the booking horizon, and render the result in the invitee's zone with the zone named in words. Decide and write down what happens to a rule that falls in the gap on the spring transition and on the repeated hour in autumn. Trap: Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. Done when: Slots on both clock change days match the documented rule, asserted against fixed dates. An invitee in another zone sees the same instant in their own zone with the zone named. Buffers, minimum notice and the horizon each narrow the offered set, tested separately. A busy block that partly overlaps a slot removes the slot rather than shortening it. Do not build yet: No booking, holds, email or invitations yet. ### Phase 3 · Booking exactly once Build: Insert the booking first, inside a transaction, and let the unique constraint decide. Only after it commits create the calendar event and store the remote identifier on the row. Hold a slot for a short window while the form is being completed, with the hold expiring by its own timestamp so correctness never depends on a cleanup job running. Trap: Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. Done when: Two concurrent requests for the same slot produce one booking and one taken response, tested with parallel calls. A failure creating the remote event leaves the booking in a retried state and never a silently confirmed one, tested by failing the provider call. An expired hold releases its slot with no background job running. Each booking records the remote event identifier once it exists. Do not build yet: No confirmations, reschedules or cancellations yet. ### Phase 4 · The calendar entry that updates instead of duplicating Build: Send confirmations carrying an iCalendar attachment with a stable identifier, sequence zero and a request method. A reschedule keeps the identifier, increments the sequence and sends a request; a cancellation keeps the identifier and sends a cancel. Put both parties' zones in the body text as well as in the attachment. Trap: Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. Done when: A reschedule imported into a calendar client replaces the original entry, verified by importing both files rather than by reading the code. A cancellation removes it. The sequence increments on every change and never repeats for one booking. The confirmation body states the time in both zones with the zone names spelled out. Do not build yet: No rate limiting, export or deletion yet. ### Phase 5 · A public form on the internet Build: Rate limit the booking endpoint per address and per event type, cap bookings per day, and require the invitee to confirm through an emailed link before the slot is held beyond the short window. Export every booking as CSV and iCalendar. Delete an invitee's personal fields on request while keeping the slot record, and write the deletion to the audit table. Never render invitee input as markup anywhere. Trap: A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. Done when: A flood from one address is rejected after the stated rate, tested. An unconfirmed booking releases its slot after the stated window. Export produces CSV and iCalendar that reimport correctly into a fresh instance and a calendar client. An invitee deletion removes personal fields, keeps the slot record and writes an audit row, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Team round robin and shared availability, which multiply every zone and conflict problem by the number of people and are most of what the paid product sells. - Taking payment at booking, which adds a refund path to every cancellation and a compliance surface this build does not want. - Several calendars across different providers, where each one's idea of busy differs and the reconciliation is a project of its own. - Text message reminders, which need a second provider, per-country rules and consent handling. ### README must contain - That the promise not to double-book is a unique constraint on event type and start instant, not a check - That only free and busy leaves the calendar, and that no column exists for an event title - That a refresh failure pauses the page, because an empty busy list would otherwise read as complete availability - That an OAuth consent screen left in testing publishing status issues short-lived refresh tokens, and what that does a week later - That invitations keep one identifier and increment a sequence, so a reschedule replaces rather than duplicates
# Agent instructions · lemcal indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One server at a stable public address with a persistent database, One provider through OAuth, refresh token encrypted at rest, PostgreSQL with a unique constraint on event type and start instant, Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library, iCalendar attachments with a stable identifier and an incrementing sequence, One transactional provider sending from an authenticated domain. 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 - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client.
# Build plan · lemcal A personal booking page whose hard problems are keeping one calendar connected through token expiry, computing availability that stays correct across a clock change, and making the promise not to double-book a database constraint rather than a check. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Connecting one calendar and noticing when it disconnects The calendar stays connected, and a dead token pauses the page instead of emptying it. ### Steps 1. Complete the flow and encrypt the token At rest, with the key in the environment and never in the database. Files: `src/auth/oauth.ts`, `src/crypto/tokens.ts` 2. Check the consent screen's publishing status A testing project hands out tokens that die in a week. Files: `docs/oauth-setup.md` 3. Make a refresh failure a paused state An empty busy list must never mean wide open. Files: `src/calendar/sync.ts`, `tests/paused.test.ts` ### Done when - [ ] A dead token pauses the page and offers no slots - [ ] An empty busy list is never read as availability - [ ] The refresh token is encrypted and never logged - [ ] The alert fires once, not per request ### Watch out - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. ## Phase 2 · Availability that survives the clock change Slots come from the zone's rules for each date, never from a fixed offset. ### Steps 1. Expand rules per date, not per offset The zone's rules for that date are the only correct source. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 2. Subtract busy, buffers, notice and horizon in that order Each is a separate test, because each is separately wrong. Files: `src/availability/filters.ts` 3. Name the zone everywhere a time is shown Two o'clock without a zone is a meeting somebody misses. Files: `src/ui/slots.tsx` ### Done when - [ ] Both clock change days match the documented rule - [ ] Times render in the invitee's zone, named - [ ] Buffers, notice and horizon each narrow the set - [ ] A partial overlap removes the whole slot ### Watch out - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. ## Phase 3 · Booking exactly once Two people clicking the same slot produce one booking and one clear message. ### Steps 1. Insert first and let the constraint decide A check before a write is a race with a friendly name. Files: `src/booking/create.ts`, `tests/concurrent.test.ts` 2. Create the remote event after the commit And record its identifier, so a retry does not make a second one. Files: `src/calendar/events.ts` 3. Expire holds by timestamp Correctness should not depend on a cron job being alive. Files: `src/booking/holds.ts` ### Done when - [ ] Concurrent bookings yield one booking - [ ] A failed remote create is retried, not confirmed - [ ] Expired holds free their slot without a job - [ ] The remote identifier is stored on the row ### Watch out - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. ## Phase 4 · The calendar entry that updates instead of duplicating A reschedule replaces the invitee's event rather than adding a second one. ### Steps 1. Keep one identifier for the life of a booking A new one means a second entry in their calendar. Files: `src/ics/build.ts`, `tests/ics.test.ts` 2. Increment the sequence on every change Clients use it to decide whether your update wins. Files: `src/ics/build.ts` 3. Import both files into a real client The only test that proves a reschedule replaced anything. Files: `docs/ics-verification.md` ### Done when - [ ] A reschedule replaces the original entry - [ ] A cancellation removes it - [ ] The sequence increments and never repeats - [ ] Both zones appear in the body text ### Watch out - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ## Not in this build - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. ## After v1, if you want it - A second event type with its own buffers and cap, which the schema already carries - Reminder emails at a fixed lead time, once the send path has a month without a duplicate - A read-only availability feed for a colleague, which needs no new calendar permissions
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Bookings, holds and the audit trail. DATABASE_URL=postgres://localhost:5432/booking # Required. Used in the OAuth redirect and in every confirmation link. PUBLIC_BASE_URL=https://book.example.com # Required. From the OAuth client you created. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. Never rendered on an error page, which is where secrets usually escape. GOOGLE_CLIENT_SECRET=sk-...redacted # Required · secret. Encrypts the refresh token at rest. Losing it means reconnecting the calendar. TOKEN_ENCRYPTION_KEY=sk-...redacted # Required. An IANA name. Availability rules are written and expanded in it. OWNER_TIMEZONE=Europe/London # Required · secret. Confirmations, reschedules and cancellations. EMAIL_API_KEY=sk-...redacted # Required. How long an unconfirmed slot is held. Expires by timestamp, not by a job. HOLD_MINUTES=10
# lemcal · product brief ## Problem The visible sales scheduling loop is buildable, but a credible replacement needs more than the first screen. lemcal earns its keep through OAuth, calendar reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A booking page that offers only real availability in the invitee's own zone, cannot be made to double-book by two people clicking at once, reveals nothing about your calendar beyond busy and free, and says it is paused rather than quietly offering every hour of your week. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Google Calendar OAuth credentials - hosted callback URL - email provider API key in .env ## Explicit non-goals for v1 - Team round robin and shared availability. - Payment at the point of booking. - Several calendars across different providers. - Text message reminders. - race-free booking under load - team routing and round robin - reliable reminders and timezone edge cases - calendar-provider edge cases and timezone correctness - multi-calendar conflict handling ## Success criteria - A dead token is confirmed to pause the page rather than open it - Both clock change days are confirmed to match the documented rule - Concurrent bookings are confirmed to yield exactly one booking - A reschedule is confirmed to replace the entry in a real calendar client - No calendar field beyond busy and free is confirmed to reach the public page
# Build brief · lemcal The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal booking page that reads one calendar and cannot double-book you to replace lemcal. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two promises, and a booking tool that breaks either is worse than a shared spreadsheet. Never double-book. Two people on the same link clicking the same slot within the same second is not a rare case, it is what happens the moment the link is actually useful. The protection is a unique constraint on the event type and the start instant, so the second insert fails and that person is told the slot went, rather than an availability check that returned true for both. Never leak the calendar. What crosses from your calendar to a public page is busy and free and nothing else. Not a title, not an attendee, not a location, not a joining link, not a duration somebody could use to guess what the meeting is. A page that quietly reveals 'busy, interview, two hours' has done more damage than every double booking it prevented. ### Stack (fixed, do not substitute) - Runtime: One server at a stable public address with a persistent database · the OAuth redirect and the invitee both need an address that does not move - Calendar: One provider through OAuth, refresh token encrypted at rest · this is the whole product and also the part that quietly stops working - Storage: PostgreSQL with a unique constraint on event type and start instant · the promise is enforced by the database or it is not enforced - Time: Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library · nine in the morning is two different moments in the week the clocks move - Invitations: iCalendar attachments with a stable identifier and an incrementing sequence · a reschedule must replace the invitee's event, not add a second one - Email: One transactional provider sending from an authenticated domain · a confirmation in the spam folder is a missed meeting ### Data model (create this before Phase 1) - accounts(id, provider, calendar_id, refresh_token_enc, token_state, scopes, connected_at, last_sync_at, last_error) · token_state is what pauses the page - event_types(id, slug, name, duration_minutes, buffer_before, buffer_after, min_notice_minutes, max_days_ahead, daily_cap, active) - availability_rules(event_type_id, weekday, start_local, end_local, owner_timezone) · written in local time, expanded per date - busy_blocks(account_id, starts_at, ends_at, fetched_at) · free and busy only, and there is no column for a title - bookings(id, event_type_id, starts_at, ends_at, invitee_name, invitee_email, invitee_timezone, state, remote_event_id, ics_uid, ics_sequence, created_at, cancelled_at) - unique(event_type_id, starts_at) where state is booked · one line of schema doing the work of an entire class of bug - holds(event_type_id, starts_at, session_token, expires_at) · a slot reserved while a form is being filled, expiring without a cleanup job - audit(at, actor, action, booking_id, detail) · who cancelled what, because the owner and the invitee will remember it differently ### Phase 1 · Connecting one calendar and noticing when it disconnects Build: Complete the OAuth flow for reading free and busy and writing events, store the refresh token encrypted, and refresh ahead of expiry. Treat a refresh failure as a state rather than an exception: the public page says bookings are paused and the owner is alerted once. Check your consent screen's publishing status before you rely on the connection lasting. Trap: A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. Done when: A refresh failure sets the token state to paused, the public page says so and offers no slots, tested by invalidating the token. An empty busy list from a failed fetch is never treated as availability, asserted in the suite. The refresh token is encrypted at rest and never appears in a log or an error page. The alert fires once per failure rather than once per request. Do not build yet: No availability rules, no booking, no email. ### Phase 2 · Availability that survives the clock change Build: Expand availability rules per date in the owner's zone, subtract busy blocks and buffers, apply minimum notice and the booking horizon, and render the result in the invitee's zone with the zone named in words. Decide and write down what happens to a rule that falls in the gap on the spring transition and on the repeated hour in autumn. Trap: Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. Done when: Slots on both clock change days match the documented rule, asserted against fixed dates. An invitee in another zone sees the same instant in their own zone with the zone named. Buffers, minimum notice and the horizon each narrow the offered set, tested separately. A busy block that partly overlaps a slot removes the slot rather than shortening it. Do not build yet: No booking, holds, email or invitations yet. ### Phase 3 · Booking exactly once Build: Insert the booking first, inside a transaction, and let the unique constraint decide. Only after it commits create the calendar event and store the remote identifier on the row. Hold a slot for a short window while the form is being completed, with the hold expiring by its own timestamp so correctness never depends on a cleanup job running. Trap: Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. Done when: Two concurrent requests for the same slot produce one booking and one taken response, tested with parallel calls. A failure creating the remote event leaves the booking in a retried state and never a silently confirmed one, tested by failing the provider call. An expired hold releases its slot with no background job running. Each booking records the remote event identifier once it exists. Do not build yet: No confirmations, reschedules or cancellations yet. ### Phase 4 · The calendar entry that updates instead of duplicating Build: Send confirmations carrying an iCalendar attachment with a stable identifier, sequence zero and a request method. A reschedule keeps the identifier, increments the sequence and sends a request; a cancellation keeps the identifier and sends a cancel. Put both parties' zones in the body text as well as in the attachment. Trap: Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. Done when: A reschedule imported into a calendar client replaces the original entry, verified by importing both files rather than by reading the code. A cancellation removes it. The sequence increments on every change and never repeats for one booking. The confirmation body states the time in both zones with the zone names spelled out. Do not build yet: No rate limiting, export or deletion yet. ### Phase 5 · A public form on the internet Build: Rate limit the booking endpoint per address and per event type, cap bookings per day, and require the invitee to confirm through an emailed link before the slot is held beyond the short window. Export every booking as CSV and iCalendar. Delete an invitee's personal fields on request while keeping the slot record, and write the deletion to the audit table. Never render invitee input as markup anywhere. Trap: A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked. Done when: A flood from one address is rejected after the stated rate, tested. An unconfirmed booking releases its slot after the stated window. Export produces CSV and iCalendar that reimport correctly into a fresh instance and a calendar client. An invitee deletion removes personal fields, keeps the slot record and writes an audit row, tested end to end. Do not build yet: Nothing after this. ### Out of scope (and why) - Team round robin and shared availability, which multiply every zone and conflict problem by the number of people and are most of what the paid product sells. - Taking payment at booking, which adds a refund path to every cancellation and a compliance surface this build does not want. - Several calendars across different providers, where each one's idea of busy differs and the reconciliation is a project of its own. - Text message reminders, which need a second provider, per-country rules and consent handling. ### README must contain - That the promise not to double-book is a unique constraint on event type and start instant, not a check - That only free and busy leaves the calendar, and that no column exists for an event title - That a refresh failure pauses the page, because an empty busy list would otherwise read as complete availability - That an OAuth consent screen left in testing publishing status issues short-lived refresh tokens, and what that does a week later - That invitations keep one identifier and increment a sequence, so a reschedule replaces rather than duplicates
# Architecture · lemcal ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One server at a stable public address with a persistent database | the OAuth redirect and the invitee both need an address that does not move | | Calendar | One provider through OAuth, refresh token encrypted at rest | this is the whole product and also the part that quietly stops working | | Storage | PostgreSQL with a unique constraint on event type and start instant | the promise is enforced by the database or it is not enforced | | Time | Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library | nine in the morning is two different moments in the week the clocks move | | Invitations | iCalendar attachments with a stable identifier and an incrementing sequence | a reschedule must replace the invitee's event, not add a second one | | Email | One transactional provider sending from an authenticated domain | a confirmation in the spam folder is a missed meeting | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Connection | OAuth, refresh, encryption and the paused state. | Any provider, provided a failed fetch never becomes an empty busy list. | | Availability | Per-date expansion, buffers, notice and horizon. | Any implementation, provided both clock change days stay tested. | | Booking | The constraint, holds and remote event creation. | Not swappable for check-then-insert, which is the race this build exists to avoid. | | Invitations | Stable identifiers, sequences and cancellations. | Any generator, provided the identifier survives a reschedule. | | Public page | Rate limits, confirmation, export and erasure. | Any front end, provided no calendar field beyond busy and free reaches it. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required · Bookings, holds and the audit trail. - `PUBLIC_BASE_URL` · required · Used in the OAuth redirect and in every confirmation link. - `GOOGLE_CLIENT_ID` · required · From the OAuth client you created. - `GOOGLE_CLIENT_SECRET` · required, secret · Never rendered on an error page, which is where secrets usually escape. - `TOKEN_ENCRYPTION_KEY` · required, secret · Encrypts the refresh token at rest. Losing it means reconnecting the calendar. - `OWNER_TIMEZONE` · required · An IANA name. Availability rules are written and expanded in it. - `EMAIL_API_KEY` · required, secret · Confirmations, reschedules and cancellations. - `HOLD_MINUTES` · required · How long an unconfirmed slot is held. Expires by timestamp, not by a job. ## 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 · lemcal product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One server at a stable public address with a persistent database, One provider through OAuth, refresh token encrypted at rest, PostgreSQL with a unique constraint on event type and start instant, Instants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone library, iCalendar attachments with a stable identifier and an incrementing sequence, One transactional provider sending from an authenticated domain. - 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 - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. - A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked.
# Delivery milestones · lemcal Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Connecting one calendar and noticing when it disconnects The calendar stays connected, and a dead token pauses the page instead of emptying it. ### Steps 1. Complete the flow and encrypt the token At rest, with the key in the environment and never in the database. Files: `src/auth/oauth.ts`, `src/crypto/tokens.ts` 2. Check the consent screen's publishing status A testing project hands out tokens that die in a week. Files: `docs/oauth-setup.md` 3. Make a refresh failure a paused state An empty busy list must never mean wide open. Files: `src/calendar/sync.ts`, `tests/paused.test.ts` ### Done when - [ ] A dead token pauses the page and offers no slots - [ ] An empty busy list is never read as availability - [ ] The refresh token is encrypted and never logged - [ ] The alert fires once, not per request ### Watch out - A Google Cloud project whose OAuth consent screen is still in testing publishing status issues refresh tokens that expire after seven days, so the page you demonstrated on Monday stops reading the calendar the following Monday, and because a failed fetch returns an empty busy list, the failure mode is offering every hour you have rather than none. Publish the consent screen or accept the weekly reauthentication deliberately, and make a refresh failure pause the page instead of producing an empty calendar. ## M2 · Availability that survives the clock change Slots come from the zone's rules for each date, never from a fixed offset. ### Steps 1. Expand rules per date, not per offset The zone's rules for that date are the only correct source. Files: `src/availability/expand.ts`, `tests/dst.test.ts` 2. Subtract busy, buffers, notice and horizon in that order Each is a separate test, because each is separately wrong. Files: `src/availability/filters.ts` 3. Name the zone everywhere a time is shown Two o'clock without a zone is a meeting somebody misses. Files: `src/ui/slots.tsx` ### Done when - [ ] Both clock change days match the documented rule - [ ] Times render in the invitee's zone, named - [ ] Buffers, notice and horizon each narrow the set - [ ] A partial overlap removes the whole slot ### Watch out - Generating slots by adding thirty minute steps to a stored offset produces times that are an hour out for half the year, and it arrives as a client saying the nine o'clock slot booked them for ten, weeks later, when you have forgotten you touched it. Expand each date through a zone-aware library, store the resulting instants, and write a test pinned to both transition dates. ## M3 · Booking exactly once Two people clicking the same slot produce one booking and one clear message. ### Steps 1. Insert first and let the constraint decide A check before a write is a race with a friendly name. Files: `src/booking/create.ts`, `tests/concurrent.test.ts` 2. Create the remote event after the commit And record its identifier, so a retry does not make a second one. Files: `src/calendar/events.ts` 3. Expire holds by timestamp Correctness should not depend on a cron job being alive. Files: `src/booking/holds.ts` ### Done when - [ ] Concurrent bookings yield one booking - [ ] A failed remote create is retried, not confirmed - [ ] Expired holds free their slot without a job - [ ] The remote identifier is stored on the row ### Watch out - Checking availability and then inserting leaves a window of a few hundred milliseconds, and that window is only ever hit just after the link has been shared with a group, which is exactly the moment you needed it to hold. Let the constraint reject the second insert, and turn the rejection into a message offering the next three available slots. ## M4 · The calendar entry that updates instead of duplicating A reschedule replaces the invitee's event rather than adding a second one. ### Steps 1. Keep one identifier for the life of a booking A new one means a second entry in their calendar. Files: `src/ics/build.ts`, `tests/ics.test.ts` 2. Increment the sequence on every change Clients use it to decide whether your update wins. Files: `src/ics/build.ts` 3. Import both files into a real client The only test that proves a reschedule replaced anything. Files: `docs/ics-verification.md` ### Done when - [ ] A reschedule replaces the original entry - [ ] A cancellation removes it - [ ] The sequence increments and never repeats - [ ] Both zones appear in the body text ### Watch out - Generating a fresh identifier for a rescheduled invitation leaves the original sitting in the invitee's calendar beside the new one, and half of them turn up at the old time because that is the entry their phone reminded them about. Keep the identifier for the life of the booking, increment the sequence, and test by importing both files into a real calendar client. ## M5 · A public form on the internet (production only) The page survives strangers, and the owner can leave with everything. ### Steps 1. Confirm by email before holding the slot The cheapest filter against scripted bookings. Files: `src/booking/confirm.ts`, `tests/abuse.test.ts` 2. Rate limit and cap per day Per address and per event type, with a readable refusal. Files: `src/middleware/ratelimit.ts` 3. Export and delete, both tested Leaving should be as easy as arriving. Files: `src/export/bookings.ts`, `src/privacy/erase.ts` ### Done when - [ ] A flood from one address is rejected - [ ] Unconfirmed bookings release their slot - [ ] Export reimports into a client correctly - [ ] Deletion clears personal fields and audits it ### Watch out - A public booking link is an unauthenticated write endpoint and scripts will find it, so the weekend after you share it publicly your calendar has forty meetings with nonsense names and you cannot tell which two were real. Rate limit per address, cap per day, and hold the slot only until an emailed confirmation link is clicked.
# Operations · lemcal ## Backup The bookings table and the audit trail. The calendar itself is backed up by the provider, but your record of who booked what and who cancelled it exists only here, and it is the thing you will need when a client says they never cancelled. ## Restore Restore, then reconnect the calendar, because the encrypted refresh token may predate a key rotation. Confirm the paused state clears and that a test booking still lands in the real calendar before sharing the link again. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Time since the last successful busy fetch, count of paused states, rejected duplicate inserts and unconfirmed holds expiring. A rise in the last two usually means the link has been shared somewhere public. ## Incident checklist A double booking means the constraint is missing or the insert path bypassed it, and the audit table shows both rows. A quiet page nearly always means an expired refresh token, which is why the paused banner exists rather than an empty grid. 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 - [ ] A dead token is confirmed to pause the page rather than open it - [ ] Both clock change days are confirmed to match the documented rule - [ ] Concurrent bookings are confirmed to yield exactly one booking - [ ] A reschedule is confirmed to replace the entry in a real calendar client - [ ] No calendar field beyond busy and free is confirmed to reach the public page ## Launch constraint Do not market omitted lemcal 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. Bookings, holds and the audit trail. DATABASE_URL=postgres://localhost:5432/booking # Required. Used in the OAuth redirect and in every confirmation link. PUBLIC_BASE_URL=https://book.example.com # Required. From the OAuth client you created. GOOGLE_CLIENT_ID=1234567890-abcdef.apps.googleusercontent.com # Required · secret. Never rendered on an error page, which is where secrets usually escape. GOOGLE_CLIENT_SECRET=sk-...redacted # Required · secret. Encrypts the refresh token at rest. Losing it means reconnecting the calendar. TOKEN_ENCRYPTION_KEY=sk-...redacted # Required. An IANA name. Availability rules are written and expanded in it. OWNER_TIMEZONE=Europe/London # Required · secret. Confirmations, reschedules and cancellations. EMAIL_API_KEY=sk-...redacted # Required. How long an unconfirmed slot is held. Expires by timestamp, not by a job. HOLD_MINUTES=10
$ choose a build depth, inspect the files, then open the complete pack in your agent
lemcal: People pay because a booking tool must never double-book them, leak private events, or send the wrong time to a client.
xrace-free booking under load
xteam routing and round robin
xreliable reminders and timezone edge cases
xcalendar-provider edge cases and timezone correctness
xmulti-calendar conflict handling
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
lemcal pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| starter | $0/user | $0/user | 1 calendar; 3 active meeting types; 3 availability schedules on the monthly comparison. |
| pro | $9/user | $7/user | 5 calendars; unlimited meeting types; unlimited availability schedules. |
free tier1 user; 1 calendar; 3 active meeting types; the availability-schedule cap is internally inconsistent (3 on the monthly comparison, unlimited on the annual comparison).
billingmonthly + annual (-22%)
verified 2026-08-14 · source ↗
Vibecode lemcal
Kinda. The core of lemcal is buildable in a weekend with the prompt on this page, but there are real gaps: race-free booking under load, team routing and round robin. Read the honest list above before committing.
How much does lemcal cost?
lemcal'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 lemcal?
Honestly: race-free booking under load; team routing and round robin; reliable reminders and timezone edge cases; calendar-provider edge cases and timezone correctness; multi-calendar conflict handling. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to lemcal?
Yes: zcal Free (Personalized one-use links, branded pages, reminders and Zapier are free; round-robin sales routing is paid.) The prompt is for when you want it exactly your way.