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 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
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:
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
- If NULL: computed as
- 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!)
→ 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 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] = 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:
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 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)
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