Build lemcal

KINDAback to the verdict

0%0 of 18 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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 weekendthe files for this build are in the project pack

RuntimeOne server at a stable public address with a persistent databaseCalendarOne provider through OAuth, refresh token encrypted at restStoragePostgreSQL with a unique constraint on event type and start instantTimeInstants in UTC with IANA zone names stored beside them, arithmetic through a maintained zone libraryInvitationsiCalendar attachments with a stable identifier and an incrementing sequenceEmailOne transactional provider sending from an authenticated domain

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the server, the OAuth flow and the tests.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree 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

  3. 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. open ↗

    Verify A refresh succeeds, and the publishing status is recorded in your notes

  4. have readya 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

  5. API keyfree 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

  6. decidefree

    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

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- 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

Environment variables

These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.

VariableNeededExampleWhere the value comes from
DATABASE_URLrequiredpostgres://localhost:5432/bookingBookings, holds and the audit trail.
PUBLIC_BASE_URLrequiredhttps://book.example.comUsed in the OAuth redirect and in every confirmation link.
GOOGLE_CLIENT_IDrequired1234567890-abcdef.apps.googleusercontent.comFrom the OAuth client you created.
GOOGLE_CLIENT_SECRETsecretrequiredsk-...redactedNever rendered on an error page, which is where secrets usually escape.
TOKEN_ENCRYPTION_KEYsecretrequiredsk-...redactedEncrypts the refresh token at rest. Losing it means reconnecting the calendar.
OWNER_TIMEZONErequiredEurope/LondonAn IANA name. Availability rules are written and expanded in it.
EMAIL_API_KEYsecretrequiredsk-...redactedConfirmations, reschedules and cancellations.
HOLD_MINUTESrequired10How long an unconfirmed slot is held. Expires by timestamp, not by a job.

The build, in order

  1. Connecting one calendar and noticing when it disconnects

    The calendar stays connected, and a dead token pauses the page instead of emptying it.

    1. At rest, with the key in the environment and never in the database.

      Files src/auth/oauth.tssrc/crypto/tokens.ts

    2. A testing project hands out tokens that die in a week.

      Files docs/oauth-setup.md

    3. An empty busy list must never mean wide open.

      Files src/calendar/sync.tstests/paused.test.ts

    done when · tick each as it passes
    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.
  2. Availability that survives the clock change

    Slots come from the zone's rules for each date, never from a fixed offset.

    1. The zone's rules for that date are the only correct source.

      Files src/availability/expand.tstests/dst.test.ts

    2. Each is a separate test, because each is separately wrong.

      Files src/availability/filters.ts

    3. Two o'clock without a zone is a meeting somebody misses.

      Files src/ui/slots.tsx

    done when · tick each as it passes
    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.
  3. Booking exactly once

    Two people clicking the same slot produce one booking and one clear message.

    1. A check before a write is a race with a friendly name.

      Files src/booking/create.tstests/concurrent.test.ts

    2. And record its identifier, so a retry does not make a second one.

      Files src/calendar/events.ts

    3. Correctness should not depend on a cron job being alive.

      Files src/booking/holds.ts

    done when · tick each as it passes
    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.
  4. The calendar entry that updates instead of duplicating

    A reschedule replaces the invitee's event rather than adding a second one.

    1. A new one means a second entry in their calendar.

      Files src/ics/build.tstests/ics.test.ts

    2. Clients use it to decide whether your update wins.

      Files src/ics/build.ts

    3. The only test that proves a reschedule replaced anything.

      Files docs/ics-verification.md

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.