Fixing a Race Condition in the SailJADA Booking Calendar: Synchronous State Management for Availability Data

What Was Done

We identified and resolved a critical race condition in the booking flow on staging.sailjada.com that allowed users to select unavailable time slots. The issue occurred because the booking modal became interactive before the availability calendar data finished loading from external APIs. This post details the technical approach, infrastructure changes, and deployment strategy.

The Problem: Asynchronous Loading Without Gates

The booking system used a function called jadaOpenBook() defined in /Users/cb/Documents/repos/sites/sailjada.com/index.html (and replicated across 22+ page templates) that opened a modal booking interface. The critical issue:

  • The modal opened immediately via DOM manipulation targeting jada-modal-overlay elements
  • Availability data loaded asynchronously via fetch() calls to external booking providers (GetMyBoat, Viator, and internal calendar APIs)
  • The calendar UI became clickable during the fetch phase, before availability data populated
  • Users could book slots that were already reserved

The root cause: no synchronization mechanism between modal opening and data availability. The code pattern looked conceptually like:

function jadaOpenBook() {
  // Show modal immediately
  document.querySelector('.jada-modal-overlay').style.display = 'block';
  
  // Fetch happens asynchronously - modal already visible!
  fetch('/api/availability')
    .then(response => response.json())
    .then(data => renderCalendar(data));
}

Technical Solution: Synchronous State Gating

We implemented a state management pattern using a jadaBookingState object that gates modal interactivity until data loads. The fix involved three components:

1. State Management Object

Added a booking state tracker to prevent user interaction before readiness:

const jadaBookingState = {
  isLoading: true,
  dataReady: false,
  availabilityData: null,
  
  setReady: function(data) {
    this.availabilityData = data;
    this.dataReady = true;
    this.isLoading = false;
  },
  
  isInteractable: function() {
    return this.dataReady && !this.isLoading;
  }
};

2. Modified jadaOpenBook() Function

Updated the function to block modal interactivity until data arrives:

async function jadaOpenBook() {
  const modal = document.querySelector('.jada-modal-overlay');
  const calendar = document.querySelector('[data-jada-calendar]');
  
  // Show loading state
  modal.style.display = 'block';
  calendar.setAttribute('aria-busy', 'true');
  calendar.style.pointerEvents = 'none';
  
  try {
    const availability = await Promise.all([
      fetch('/api/availability').then(r => r.json()),
      fetch('/api/pricing').then(r => r.json())
    ]);
    
    jadaBookingState.setReady(availability);
    calendar.style.pointerEvents = 'auto';
    calendar.setAttribute('aria-busy', 'false');
    renderCalendar(availability);
    
  } catch (error) {
    console.error('Booking load failed:', error);
    modal.style.display = 'none';
    showErrorMessage('Unable to load booking calendar');
  }
}

3. Click Handler Protection

Added validation to slot selection handlers:

document.addEventListener('click', function(e) {
  if (e.target.matches('[data-jada-slot]')) {
    if (!jadaBookingState.isInteractable()) {
      e.preventDefault();
      e.stopPropagation();
      return false;
    }
    // Process slot selection
    handleSlotSelection(e.target.dataset.jadaSlot);
  }
});

File Changes and Deployment

The race condition existed in templated pages across the SailJADA site. We identified affected files using recursive grep patterns:

grep -l "jadaOpenBook" /Users/cb/Documents/repos/sites/sailjada.com/**/*.html

This revealed the pattern affected:

  • /index.html (homepage with embedded booking widget)
  • /about/index.html
  • /contact/index.html
  • /sd-sailing-calendar/index.html
  • 20+ additional charter and service pages

We created a Python automation script to apply the fix consistently across all 22 files:

#!/usr/bin/env python3
import os
import glob
import re

files = glob.glob('/Users/cb/Documents/repos/sites/sailjada.com/**/*.html', recursive=True)

for filepath in files:
  with open(filepath, 'r') as f:
    content = f.read()
  
  if 'jadaOpenBook' in content and 'jadaBookingState' not in content:
    # Inject state management before first jadaOpenBook call
    state_code = ''''''
    content = state_code + content
    
    with open(filepath, 'w') as f:
      f.write(content)
    print(f"✓ Fixed {filepath}")

Infrastructure and Deployment Strategy

Staging Deployment

Per the staging validation rule, we deployed to s3://queenofsandiego.com/_staging/sailjada/ for review before production:

aws s3 sync /Users/cb/Documents/repos/sites/sailjada.com \
  s3://queenofsandiego.com/_staging/sailjada/ \
  --exclude ".git*" \
  --exclude "*.map" \
  --cache-control "max-age=300"

This created a parallel staging environment at https://queenofsandiego.com/_staging/sailjada/ for validation testing.

Production Deployment

The production site is hosted on s3://sailjada-prod with CloudFront distribution caching. Key infrastructure:

  • S3 Origin Bucket: sailjada-prod
  • CloudFront Distribution ID: (environment-specific, sourced from .secrets/repos.env)
  • Route53 DNS: sailjada.com ALIAS record pointing to CloudFront
  • Cache Invalidation: Automatic for all *.html files post-deploy

Deployment command pattern (credentials sourced from secrets):

set -a; source /Users/cb/Documents/repos/.secrets/repos.env; set +a

aws s3 sync /Users/cb/Documents/repos/sites/sailjada.com \
  s3://sailjada-prod \
  --delete \
  --exclude ".git*" \
  --