Skip to content

Availability Calculation Engine

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)
  • Appointment type configuration (duration, gap, horizon)

All calculations are timezone-aware and handle DST transitions correctly.

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

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:

Components:

  • slots_open_at (optional): when booking opens
    • If NULL: defaults to today at midnight UTC
  • slots_close_at (optional): when booking closes
    • If NULL: computed as slots_open_at + slots_horizon_days
  • Current time: rounded up to next whole minute

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 + slots_horizon_days

visible_start = max(start, current_time_rounded_up)
visible_window = [visible_start, end)

Example:

slots_open_at: NULL
slots_close_at: NULL
slots_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:

Appointment type:
  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 an appointment type, 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: timeslots:{appointmentTypeId}:{specialistId|pooled}
  • TTL: 5 minutes (configurable)
  • Invalidated on: appointment create/cancel/reschedule, weekly hours change, override change, appointment type config change

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: Existing overrides use old timezone for start/end dates

Solution: Block timezone change if appointment-type-specific overrides exist

  • Admin must delete overrides first, or
  • Manual migration script to recompute override dates in new timezone

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)

Case 3: Fall-Back (Repeated Hour)

Problem: During fall-back DST, 01:00-02:00 happens twice

Solution: Go's time.Date() in a timezone location always resolves to the first occurrence (standard time)

  • Slots generated during the repeated hour use standard time offset
  • Second occurrence is skipped (unavailable for booking)
  • This is the expected behavior — scheduling systems typically only use the first occurrence

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 appointment type = 1000ms saved

Reference Implementation

See go/availability.go for the complete Go implementation with:

  • Interval arithmetic (merge, intersect, subtract)
  • DST-safe timezone conversion
  • Weekly window building
  • Override and appointment application
  • Slot generation and grid alignment
  • Multi-specialist pooling
  • Slot validation