Build lemcal
KINDAback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- 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 - 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 - accountfree
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 - 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 - 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 - 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.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URL | required | postgres://localhost:5432/booking | Bookings, holds and the audit trail. |
PUBLIC_BASE_URL | required | https://book.example.com | Used in the OAuth redirect and in every confirmation link. |
GOOGLE_CLIENT_ID | required | 1234567890-abcdef.apps.googleusercontent.com | From the OAuth client you created. |
GOOGLE_CLIENT_SECRETsecret | required | sk-...redacted | Never rendered on an error page, which is where secrets usually escape. |
TOKEN_ENCRYPTION_KEYsecret | required | sk-...redacted | Encrypts the refresh token at rest. Losing it means reconnecting the calendar. |
OWNER_TIMEZONE | required | Europe/London | An IANA name. Availability rules are written and expanded in it. |
EMAIL_API_KEYsecret | required | sk-...redacted | Confirmations, reschedules and cancellations. |
HOLD_MINUTES | required | 10 | How long an unconfirmed slot is held. Expires by timestamp, not by a job. |
The build, in order
Connecting one calendar and noticing when it disconnects
The calendar stays connected, and a dead token pauses the page instead of emptying it.
At rest, with the key in the environment and never in the database.
Files
src/auth/oauth.tssrc/crypto/tokens.tsA testing project hands out tokens that die in a week.
Files
docs/oauth-setup.mdAn empty busy list must never mean wide open.
Files
src/calendar/sync.tstests/paused.test.ts
done when · tick each as it passeswatch 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.
Availability that survives the clock change
Slots come from the zone's rules for each date, never from a fixed offset.
The zone's rules for that date are the only correct source.
Files
src/availability/expand.tstests/dst.test.tsEach is a separate test, because each is separately wrong.
Files
src/availability/filters.tsTwo o'clock without a zone is a meeting somebody misses.
Files
src/ui/slots.tsx
done when · tick each as it passeswatch 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.
Booking exactly once
Two people clicking the same slot produce one booking and one clear message.
A check before a write is a race with a friendly name.
Files
src/booking/create.tstests/concurrent.test.tsAnd record its identifier, so a retry does not make a second one.
Files
src/calendar/events.tsCorrectness should not depend on a cron job being alive.
Files
src/booking/holds.ts
done when · tick each as it passeswatch 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.
The calendar entry that updates instead of duplicating
A reschedule replaces the invitee's event rather than adding a second one.
A new one means a second entry in their calendar.
Files
src/ics/build.tstests/ics.test.tsClients use it to decide whether your update wins.
Files
src/ics/build.tsThe only test that proves a reschedule replaced anything.
Files
docs/ics-verification.md
done when · tick each as it passeswatch 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.
A public form on the internetproduct builder
The page survives strangers, and the owner can leave with everything.
The cheapest filter against scripted bookings.
Files
src/booking/confirm.tstests/abuse.test.tsPer address and per event type, with a readable refusal.
Files
src/middleware/ratelimit.tsLeaving should be as easy as arriving.
Files
src/export/bookings.tssrc/privacy/erase.ts
done when · tick each as it passeswatch 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.
That is the whole plan for lemcal. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more scheduling.