Skip to content

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.

#InvariantVerified at
S1Overrides 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
S2An 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
S3Overnight 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
S4Spring-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
S5Weekly 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
S8Slot 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
S9Booking 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
S10Defaults 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
S19Availability 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

ColumnTypeNotes
idUUID PKUUIDv7 (P26)
organization_idUUID 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_idUUID NOT NULL FK specialists(id)
day_of_weekenum mon…sun
start_time, end_timeTIME NOT NULLLocal wall-clock in the specialist's scheduling_timezone, never UTC
location_idUUID NULL FK locations(id)NULL = remote/telerehab (P40, 1B.14 contract)
created_at, updated_atTIMESTAMPTZ
Unique(specialist_id, day_of_week, start_time, end_time)direct port of leo's uq_opening_dow_start_end

specialist_schedule_overrides

ColumnTypeNotes
idUUID PK
organization_idUUID NOT NULL FK organizations(id)same hard rule, same doc bug
specialist_idUUID NOT NULL FK specialists(id)
start_date, end_dateTIMESTAMPTZ NOT NULLAbsolute UTC, unlike weekly hours. This asymmetry is deliberate and is why timezone changes are dangerous (see Case 1)
availabilityBOOLEAN NOT NULLTRUE = adds availability, FALSE = blocks. See S1/S2
location_idUUID NULL FK locations(id)
calendar_idUUID NULLOPEN — §8.2. Do not write this column until the decision lands
created_at, updated_atTIMESTAMPTZ

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

sql
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:

sql
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.md Area 4 has no scope column at all, and sketches an override_weekly_hours JSONB on calendar_specialists instead
  • 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_id is NOT NULL with ON DELETE CASCADE, and the predecessor global opening_overrides table 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 intervals

Output: 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 intervals

Output: 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 pieces

Output: 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 array

Output: 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-intakes does 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 day

Registration 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):

Modehorizon_daysslots_open_at / slots_close_at
Rolling horizon> 0both NULL
Explicit range0both 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
  • 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!)
  → Pass

3. 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 availability

Team / 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] = true

Pass 2: Remaining Capacity (with appointments)

For each specialist:
  - Compute availability (weekly + overrides + appointments)
  - For each slot: increment remaining_capacity[slot]
  - Collect all unique slot times

Pass 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:

json
{
  "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 is timeslots:{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:

PolicyBehaviour
Block-with-migrateReject the timezone change (400 timezone_locked) while overrides exist; admin deletes them or runs a recompute
Allow-with-previewShow 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 time

Solution: 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:

QuestionAnswer
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).

CaseAssertsState
Spring-forward gap, Bucharest + New YorkS4 — a slot inside the vanished hour resolves forward to the first real instantfound a defect — see Case 2
Fall-back ambiguityWhich of the two instants the repeated hour yieldsresolved — see Case 3
Overnight splitS3 — Fri 20:00 → Sat 02:00 produces two windows joined at local midnight
Override replaces, not mergesS1 — a day with any override ignores weekly hours entirely
Zero-interval overrideS2 — one availability=false row spanning the day yields zero slots for that day
Grid alignmentS8 — step is duration + gap from local midnight; a locally-misaligned start is rejected
Window XOR horizonS9 — slots_close_at wins over the horizon; the illegal third state is rejected by the DB, not the engine
90-day capThe widest window the engine is asked for, crossing a DST transition on the way
DeterminismSame (calendarID, slotStartDate, candidate set) always yields the same ordering, independent of caller input order (S16)
Appointment subtractionA booking mid-window splits it in two rather than truncating
now roundingCeiling, not truncate-and-addfound 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].