Availability Calculation Engine
⚠️ PARTLY BUILT. Updated 2026-08-05. The engine is real: it lives at
services/api/internal/core/domain/scheduling/, compiles, vets, and is differential-tested against the production TypeScript original — see index.md → Reference implementation. Everything the engine reads from is still specification: no availability tables, no calendars, no repository, no routes, no hold store.
Overview
The availability engine computes available booking slots for specialists based on:
- Weekly recurring hours (wall-clock in specialist's timezone)
- Date-specific overrides (absolute UTC timestamps)
- Existing appointments (blocks booked time)
- Calendar configuration (duration, gap, horizon)
All calculations are timezone-aware. Both DST directions are now pinned by tests: spring-forward gap probing (Case 2) and fall-back ambiguity (Case 3).
Ported invariants
Every rule in this section is extracted from restartix-intakes @ 624a73e — a system that has been computing real slots against real bookings. They are numbered to match leo-port-map.md §4.1. They are not preferences; changing one changes behaviour a clinic already depends on.
| # | Invariant | Verified at |
|---|---|---|
| S1 | Overrides fully REPLACE a day — they never merge. If any override touches a local date, that date's weekly rules are skipped entirely, and only the override's availability=true intervals are added. | availability.ts:268 applyOverridesAndIntakes |
| S2 | An override with zero intervals blocks the whole day. "Block Tuesday" is expressed server-side as a single availability=false row spanning the day. There is no separate "blocked" flag. | override dialog + applyOverridesAndIntakes |
| S3 | Overnight weekly rules split at local midnight. Fri 20:00 → Sat 02:00 becomes two windows. Without the split, per-day override grouping and DST both break. | buildWeeklyWindowsUTC |
| S4 | Spring-forward gaps probe forward; they never error. A 02:30 slot on a day where 02:00–02:59 does not exist resolves to the next real instant. | safeLocalToUtc:178 |
| S5 | Weekly hours are per-specialist; override scope is undecided. In leo, weekly hours are global to the specialist and overrides are per-schedule (schedule_opening_overrides.schedule_id NOT NULL) — a specialist works 9–5 as a person but blocks Tuesday afternoons for one offering only. The platform scoping decision is §8.2, OPEN. | schema.ts:91 vs :171 |
| S8 | Slot lattice steps duration + gap on a LOCAL-midnight grid, not a UTC grid. Off-grid slot starts are rejected by isAlignedToGrid. | availability.ts:389,503 |
| S9 | Booking window is horizon XOR explicit range. useHorizon=true sets horizon_days and nulls slots_open_at/slots_close_at; useHorizon=false sets horizon to 0 and sets both timestamps. Leo enforces this only in a client-side save handler — the platform needs a DB CHECK. | settings.handlers.tsx:35-58 |
| S10 | Defaults that encode real clinic behaviour (leo column names): slotsCooldownMinutes = 1440 (24h anti-spam), minLeadTimeMinutes = 1440 (24h notice), slotsHorizonDays = 0, slotGapMinutes = 0. | schema.ts:130-140 |
| S19 | Availability responses carry a generatedAt freshness stamp, so a stale cached grid is detectable client-side. | availability.ts:55,491,764 |
Hold, cooldown, lead-time and assignment rules (S6, S7, S11–S18) live in hold-system.md.
Data model
Three tables. All are STATE, not events — flat, never partitioned (P41).
Every column below that the API filters or sorts on needs an index. Both availability tables are read on every slot computation, so
(organization_id, specialist_id)is the hot path.
specialist_weekly_hours
| Column | Type | Notes |
|---|---|---|
id | UUID PK | UUIDv7 (P26) |
organization_id | UUID NOT NULL FK organizations(id) | Hard rule. data-model.md Area 4 omits this column on both availability tables — that is a doc bug against a CLAUDE.md hard rule, tracked as conflict C2 |
specialist_id | UUID NOT NULL FK specialists(id) | |
day_of_week | enum mon…sun | |
start_time, end_time | TIME NOT NULL | Local wall-clock in the specialist's scheduling_timezone, never UTC |
location_id | UUID NULL FK locations(id) | NULL = remote/telerehab (P40, 1B.14 contract) |
created_at, updated_at | TIMESTAMPTZ | |
| Unique | (specialist_id, day_of_week, start_time, end_time) | direct port of leo's uq_opening_dow_start_end |
specialist_schedule_overrides
| Column | Type | Notes |
|---|---|---|
id | UUID PK | |
organization_id | UUID NOT NULL FK organizations(id) | same hard rule, same doc bug |
specialist_id | UUID NOT NULL FK specialists(id) | |
start_date, end_date | TIMESTAMPTZ NOT NULL | Absolute UTC, unlike weekly hours. This asymmetry is deliberate and is why timezone changes are dangerous (see Case 1) |
availability | BOOLEAN NOT NULL | TRUE = adds availability, FALSE = blocks. See S1/S2 |
location_id | UUID NULL FK locations(id) | |
calendar_id | UUID NULL | OPEN — §8.2. Do not write this column until the decision lands |
created_at, updated_at | TIMESTAMPTZ |
Single-true-availability invariant
A specialist cannot be in two places at once. Locations label availability; they never partition it. Both availability tables therefore carry a DB-level
EXCLUDE USING gist (
specialist_id WITH =,
<time-range> WITH &&
)regardless of location_id. Requires CREATE EXTENSION btree_gist (not enabled today; must run on DATABASE_DIRECT_URL per P44).
Note the scope limit: specialists.organization_id NOT NULL means one human working at two clinics has two independent specialist rows, so this guard holds within an org only. Leo has the identical limitation. Cross-org double-booking is §8.12 and is unresolved.
calendars — the booking-window CHECK
calendars is specified in data-model.md Area 4; only the F4-specific constraint is restated here. Per S9 the booking window is horizon XOR explicit range, and that must be a DB constraint, not a save handler:
CONSTRAINT chk_calendars_window_xor_horizon CHECK (
(horizon_days > 0 AND slots_open_at IS NULL AND slots_close_at IS NULL)
OR
(horizon_days = 0 AND slots_open_at IS NOT NULL AND slots_close_at IS NOT NULL)
)Leo permits the third state — horizon 0 with both timestamps NULL — when a user clears the date range without picking one. That produces a calendar with a zero-width booking window and no slots, which is a silent-failure state the platform does not accept.
Override scoping (OPEN)
specialist_schedule_overrides.calendar_id is not decided (§8.2). The three platform sources disagree:
data-model.mdArea 4 has no scope column at all, and sketches anoverride_weekly_hoursJSONB oncalendar_specialistsinstead- the specialists feature spec says
appointment_type_id— a table that exists in no architecture doc and is now a retired name - leo settles what clinics do:
schedule_opening_overrides.schedule_idisNOT NULLwithON DELETE CASCADE, and the predecessor globalopening_overridestable was explicitly removed in favour of it, with live production rows
Port-map recommendation is calendar_id UUID NULL (NULL = all calendars), which preserves the capability without forcing the UI into "read-only until you pick a calendar." It is a recommendation, not a decision. This blocks the availability-tables migration and nothing earlier.
Core Algorithm
1. Build Weekly Availability Windows (UTC)
Input: Weekly rules (wall-clock times in specialist's IANA timezone)
Process:
For each day in the booking window:
1. Determine local date and day-of-week
2. For each weekly rule matching this day:
- If normal (start < end): convert start/end to UTC
- If overnight (start >= end): split at local midnight
- Part 1: start today → midnight tomorrow
- Part 2: midnight tomorrow → end tomorrow
3. Clip to booking window
4. Merge overlapping intervalsOutput: Merged UTC intervals representing weekly availability
2. Apply Overrides
Override Semantics:
availability=true: adds availability for that day (replaces weekly hours)availability=false: blocks availability for that day (removes weekly hours)- Any override on a day causes weekly hours to be skipped for that day
Process:
Phase 1: Group overrides by local calendar day
- Each override is clipped to the booking window
- Expand multi-day overrides into per-day entries
- Build map: local_date → [override_intervals]
Phase 2: Split weekly intervals by day
- For each weekly interval, split at local midnight boundaries
- For each day piece:
- If day has override: skip (weekly hours replaced)
- If no override: keep weekly hours for that day
Phase 3: Add override intervals
- For each day with availability=true overrides: add intervals
- Days with availability=false have no intervals (blocked)
Phase 4: Merge all intervalsOutput: Merged UTC intervals with overrides applied
3. Subtract Existing Appointments
Process:
For each existing appointment:
- Clip appointment interval to booking window
- Add to "blocked" intervals list
Subtract all blocked intervals from availability:
- For each availability interval:
- For each blocked interval:
- If overlaps: split availability into 0, 1, or 2 pieces
- Merge remaining piecesOutput: Final availability intervals (UTC)
4. Generate Slots
Slot Grid Alignment:
- Step size =
slot_duration_minutes+slot_gap_minutes - Grid starts at local midnight (00:00, 00:45, 01:30, etc.)
- Slots are aligned to this grid in local timezone, then converted to UTC
Process:
For each availability interval:
1. Get local date of interval start
2. Compute minutes-from-midnight in local time
3. Snap up to next grid boundary
4. For each grid position:
- Convert local time to UTC via safeLocalToUTC()
- Check if slot fits in availability interval
- If yes: add to slots list
- Advance by step size
5. Handle local midnight rollover (continue on next day)Output: Array of slot start times (UTC)
5. Group by UTC Day
Process:
For each slot:
- Extract UTC date (YYYY-MM-DD at 00:00:00Z)
- Add slot to that date's arrayOutput: Map of UTC date key → sorted slot start times
⚠️ Known rough edge, carried faithfully from the original. Slots are generated on a local-midnight grid (S8) but grouped by UTC day.
restartix-intakesdoes the same (groupByUtcDay,availability.ts:430), so the Go port is correct-as-a-port — but for a Bucharest clinic (UTC+2/+3) a 01:00 local slot lands under the previous UTC day key. Any clinic with availability before ~03:00 local sees slots on the wrong calendar day.This is the same class of bug as leo's near-midnight
toISOString()day-bucketing in the appointments calendar view. Decide before F4 ships: keep UTC grouping for byte-parity with the legacy client, or group by the specialist's scheduling timezone. If grouping changes, the timeslot response contract changes with it.
Timezone Handling
DST-Safe Local → UTC Conversion
During spring-forward DST transitions, certain local times don't exist (e.g., 2:00 AM - 2:59 AM in US/Eastern on the second Sunday of March). The safeLocalToUTC() function handles this:
Function: safeLocalToUTC(ymd, hms, timezone)
1. Construct local time from date + time components
2. Round-trip validation:
- Convert to local timezone
- Check if wall-clock time matches original
3. If match: return UTC equivalent
4. If no match (DST gap):
- Probe forward minute-by-minute
- Find first stable time >= requested time
- Return UTC equivalent
5. Safety limit: 180 minutes (covers any real DST shift)Example:
Input: 2025-03-09, 02:30:00, America/New_York
(This time doesn't exist — clocks jump from 01:59 → 03:00)
Process:
- Construct: 2025-03-09T02:30:00 in America/New_York
- Round-trip: becomes 2025-03-09T03:30:00 (!)
- Mismatch detected
- Probe forward from 02:30:00
- 02:31 → 03:31 (no)
- 02:32 → 03:32 (no)
- ...
- 03:00 → 03:00 (match!)
- Return UTC equivalent of 03:00:00 local
Output: 2025-03-09T08:00:00Z (3am EST = 8am UTC after spring-forward)Overnight Rules
Weekly rules where end_time <= start_time span midnight:
Example: Friday 20:00 - Saturday 02:00
Splitting:
Rule: Fri 20:00 - Sat 02:00 (in America/New_York)
Split into two intervals:
1. Fri 20:00 - Sat 00:00 (local) → convert to UTC
2. Sat 00:00 - Sat 02:00 (local) → convert to UTC
Why split?
- Each piece is processed on its own calendar day
- Handles overrides correctly (Friday override vs Saturday override)
- Maintains grid alignment per local dayRegistration Window
The booking window defines which slots are visible to clients. Per S9 the two modes are mutually exclusive and enforced by chk_calendars_window_xor_horizon (see Data model):
| Mode | horizon_days | slots_open_at / slots_close_at |
|---|---|---|
| Rolling horizon | > 0 | both NULL |
| Explicit range | 0 | both set |
Components:
slots_open_at(explicit-range mode): when booking opens- If NULL (horizon mode): defaults to today at midnight UTC
slots_close_at(explicit-range mode): when booking closes- If NULL (horizon mode): computed as
slots_open_at+horizon_days
- If NULL (horizon mode): computed as
- Current time: rounded up to next whole minute
Admin reads are capped at 90 days regardless of window, and return the leo dateRange { requestedStart, requestedEnd, effectiveStart, effectiveEnd } envelope so the UI can grey out the out-of-window portion instead of silently truncating.
Min lead time is a separate gate applied at booking, not at window computation. A slot can be inside the window and still unbookable because it starts sooner than min_lead_time_minutes from now (default 1440 = 24h). See hold-system.md → Min lead time for the error shape, which is structured rather than boolean.
Window Calculation:
If slots_open_at is set:
start = slots_open_at
Else:
start = today at midnight UTC
If slots_close_at is set:
end = slots_close_at
Else:
end = start + horizon_days
visible_start = max(start, current_time_rounded_up)
visible_window = [visible_start, end)Example:
slots_open_at: NULL
slots_close_at: NULL
horizon_days: 30
Current time: 2025-03-15 14:37:23 UTC
Computed window:
start: 2025-03-15 00:00:00 UTC
end: 2025-04-14 00:00:00 UTC (start + 30 days)
visible_start: 2025-03-15 14:38:00 UTC (rounded up)
visible_window: [2025-03-15 14:38:00 UTC, 2025-04-14 00:00:00 UTC)Slot Validation
When a client attempts to book a slot, three checks are performed:
1. Registration Window Check
Is slotStart >= window.start?
Is slotEnd <= window.end?2. Grid Alignment Check
Convert slotStart to local timezone
Extract minutes from midnight
Is (minutes % stepSize) == 0?Example:
Calendar:
slot_duration_minutes: 30
slot_gap_minutes: 15
step: 45 minutes
Specialist timezone: America/New_York
Grid: 00:00, 00:45, 01:30, 02:15, 03:00, ...
Slot: 2025-03-15T14:00:00Z
→ Local: 2025-03-15T10:00:00 EDT
→ Minutes from midnight: 600 (10 * 60)
→ 600 % 45 = 15 (not aligned!)
→ Reject
Slot: 2025-03-15T14:45:00Z
→ Local: 2025-03-15T10:45:00 EDT
→ Minutes from midnight: 645 (10 * 60 + 45)
→ 645 % 45 = 0 (aligned!)
→ Pass3. Availability Check
Build micro-window covering only the slot [slotStart, slotEnd)
Build weekly UTC intervals for this micro-window
Apply overrides and subtract appointments
Check if slot is fully contained in resulting availabilityTeam / Multi-Specialist Availability
When multiple specialists are assigned to a calendar, availability is pooled:
Three-Pass Algorithm
Pass 1: Max Capacity (no appointments)
For each specialist:
- Compute availability (weekly + overrides, no appointments)
- For each slot: increment max_capacity[slot]
- Track specialist IDs involved: capacity_total[slot][specialist_id] = truePass 2: Remaining Capacity (with appointments)
For each specialist:
- Compute availability (weekly + overrides + appointments)
- For each slot: increment remaining_capacity[slot]
- Collect all unique slot timesPass 3: Historical Appointments
For each specialist:
- For each of their existing appointments:
- Add specialist to capacity_total[slot]
- (Captures specialists who had appointments but no current availability)Output:
{
"slots": {
"2025-03-15T00:00:00Z": ["2025-03-15T09:00:00Z", "2025-03-15T09:45:00Z"]
},
"capacity": {
"2025-03-15T09:00:00Z": {
"remaining": 2, // specialists free now
"max": 3, // specialists available if no appointments
"total": 3 // unique specialists ever involved (current + past)
}
}
}Performance Considerations
Caching
Timeslot responses are cached in Redis:
- Key:
cache.OrgResource(orgID, "timeslots", calendarID, specialistID|"pooled")— leo's key istimeslots:{scheduleId}:{openingId|pooled}with no org dimension, which is a cross-tenant cache-scope violation on this platform (P45, conflict C13) - TTL: 5 minutes (configurable)
- Invalidated on: appointment create/cancel/reschedule, weekly hours change, override change, calendar config change
- Invalidation is explicit per key, never a broad
SCAN+DEL(P45). Leo invalidates with wildcard SCAN patterns; that does not port
The Redis layer composes with P42: unstable_cache in packages/api-client is per-Next.js-process, Redis is shared across the API fleet. Both key namespaces mirror each other so one grep finds reads and invalidations across both.
Why 5 Minutes?
Balance between:
- Freshness: Clients see availability updates reasonably quickly
- Load: Availability calculation is CPU-intensive (DST handling, interval math)
- Consistency: Short enough that stale data doesn't cause many conflicts
Concurrency
Multiple clients requesting the same timeslots:
- Cache hit: instant response, no computation
- Cache miss: one request computes, others may duplicate (acceptable)
- Redis SET with TTL is atomic — no cache poisoning
Edge Cases
Case 1: Specialist Timezone Change
Problem: weekly hours are stored as local wall-clock; overrides are stored as absolute UTC. Changing scheduling_timezone silently reinterprets every weekly rule and silently desynchronises every override from the day it was meant to cover.
Status: OPEN (§8.13). Two candidate policies:
| Policy | Behaviour |
|---|---|
| Block-with-migrate | Reject the timezone change (400 timezone_locked) while overrides exist; admin deletes them or runs a recompute |
| Allow-with-preview | Show the resulting shift and require explicit confirmation |
What is not acceptable is leo's behaviour: it changes the timezone and silently shifts real availability. That is the one option ruled out.
Case 2: DST Transition During Slot
Problem: Slot starts before DST transition, ends after
Example:
Slot: 2025-03-09 01:30:00 - 02:00:00 (America/New_York)
DST: Clocks jump at 02:00:00 → 03:00:00
Result: Slot start exists, slot end doesn't exist in local timeSolution: safeLocalToUTC() handles this by probing forward
- Slot start: 01:30 local → 06:30 UTC (before transition)
- Slot end: 02:00 local → probes to 03:00 local → 07:00 UTC (after transition)
- Effective duration: 30 minutes UTC (correct)
Two things the differential test found here, both real:
1. The Go transcription probed from the wrong side, and overshot by a minute. Go's time.Date normalises a nonexistent local time using the offset before the transition, which lands it already past the gap; walking forward from there can only overshoot. Asking for 03:30 on 2026-03-29 in Bucharest returned 01:30Z where the oracle says 01:00Z — a patient offered a slot half an hour after the one the clinic configured. date-fns-tz normalises the other way, so the TS original walks up to the boundary and lands on it exactly. The port now starts a full DST shift early and walks forward, reaching the right instant without depending on which way either library normalises. This is the single defect that justified the whole differential exercise.
2. Spring-forward emits DUPLICATE slots, and that is leo's behaviour today. Every local grid point inside the vanished hour snaps to the same first-existing instant, so the slot list carries it more than once — on 2026-03-29 in Bucharest a 30-minute lattice offers 04:00 local three times. The port reproduces this faithfully (TestSpringForwardEmitsDuplicateSlots asserts it) because the engine's contract is agreement with the system running in production. Deduplication belongs at the F4 read endpoint, applied at the projection boundary and asserted separately — not silently inside the engine, where it would make the differential fixtures unusable.
Case 3: Fall-Back (Repeated Hour)
Problem: During fall-back DST, one wall-clock hour happens twice. One wall-clock time maps to two instants.
Status: RESOLVED 2026-08-05, by measurement. The bucharest-fall-back-ambiguous fixture in testdata/oracle/availability.json runs 2026-10-25 in Europe/Bucharest, where the clocks go 04:00 EEST → 03:00 EET and local 03:00–03:59 occurs twice. Answers, from the engine rather than from reasoning:
| Question | Answer |
|---|---|
| Does the repeated hour yield one bookable slot or two? | One. The lattice walks local wall-clock time, so each grid point is visited once no matter how many instants it maps to. |
| Which instant? | The second — standard time, post-transition (EET, UTC+2). Local 03:00 resolves to 01:00Z; the first occurrence at 00:00Z is never offered. |
| Do Go and the TS original agree? | Yes. This doc previously said parity "cannot be assumed" because the two use different mechanisms (time.Date vs date-fns-tz's Intl offset resolution). Measured, they pick the same instant. |
Note what is not claimed: time.Date's documentation still refuses to guarantee which zone it picks, so this is an observed behaviour pinned by a test, not a language promise. If a Go release changes it, TestAgainstOracle/bucharest-fall-back-ambiguous fails — which is the point of pinning it.
The practical outcome is the safe one: the ambiguous hour is offered once, and an hour of real availability is quietly not sold. A Romanian clinic hits this once a year at 04:00 local, which is why it survived undetected in leo.
Spring-forward, by contrast, did NOT survive the same test — see Case 2. Two defects surfaced there, one of them in the transcription itself.
Case 4: Empty Availability After Overrides
Problem: All weekly hours blocked by availability=false overrides
Result: Specialist has no available slots for those days
- Timeslot response includes days with empty arrays
- Frontend displays "No availability" for those days
- This is correct — specialist is intentionally blocked
Algorithm Complexity
For a specialist with:
- W weekly rules
- O overrides
- A existing appointments
- D days in booking window
- S slots per day
Time Complexity:
- Build weekly: O(W × D)
- Apply overrides: O(O × D)
- Subtract appointments: O(A × intervals)
- Generate slots: O(intervals × S)
- Overall: O(D × (W + O + S))
Space Complexity: O(D × S) for storing slots
Real-World Performance:
- Typical: 30-day window, 5 weekly rules, 10 overrides, 20 appointments
- Computation time: <10ms on modern CPU
- Why caching matters: 100 clients viewing same calendar = 1000ms saved
Reference Implementation
See go/availability.go for the Go transcription:
- Interval arithmetic (merge, intersect, subtract)
- DST-safe local→UTC conversion (spring-forward only; see Case 3)
- Weekly window building with overnight split
- Override and appointment application
- Slot generation and grid alignment
- Multi-specialist pooling
- Slot validation
✅ It is an implementation. services/api/internal/core/domain/scheduling/ builds, vets, lints and tests as part of make check.
The differential test suite — BUILT 2026-08-05
testdata/oracle-gen/generate.mts runs the production TypeScript engine over a fixed set of cases and writes its answers to testdata/oracle/availability.json; availability_oracle_test.go replays the same inputs through Go and asserts agreement. The fixtures are committed, so CI needs neither Node nor the sibling repo.
Two things the generator pins that the engine reads implicitly, and without which nothing reproduces: Date.now() (the engine calls it twice) and process.env.TZ (the booking window ends via date-fns addDays, which is local calendar arithmetic).
| Case | Asserts | State |
|---|---|---|
| Spring-forward gap, Bucharest + New York | S4 — a slot inside the vanished hour resolves forward to the first real instant | ✅ found a defect — see Case 2 |
| Fall-back ambiguity | Which of the two instants the repeated hour yields | ✅ resolved — see Case 3 |
| Overnight split | S3 — Fri 20:00 → Sat 02:00 produces two windows joined at local midnight | ✅ |
| Override replaces, not merges | S1 — a day with any override ignores weekly hours entirely | ✅ |
| Zero-interval override | S2 — one availability=false row spanning the day yields zero slots for that day | ✅ |
| Grid alignment | S8 — step is duration + gap from local midnight; a locally-misaligned start is rejected | ✅ |
| Window XOR horizon | S9 — slots_close_at wins over the horizon; the illegal third state is rejected by the DB, not the engine | ✅ |
| 90-day cap | The widest window the engine is asked for, crossing a DST transition on the way | ✅ |
| Determinism | Same (calendarID, slotStartDate, candidate set) always yields the same ordering, independent of caller input order (S16) | ✅ |
| Appointment subtraction | A booking mid-window splits it in two rather than truncating | ✅ |
now rounding | Ceiling, not truncate-and-add | ✅ found a defect — an unconditional +1min dropped the first slot of every request whose clock landed on :00 |
Three deliberate divergences from the oracle, each covered by its own test in availability_test.go: now is an explicit parameter rather than an ambient clock read; a zero step returns no slots instead of dividing by zero (leo's schema defaults slot_duration_minutes to 0); and an empty specialist roster returns an empty response instead of indexing specialists[0].