Ai-Whisperers avatar

appointment-optimization

Scheduling optimization algorithms for veterinary clinics including slot availability, multi-service

提供方 Ai-Whisperers|开源

Appointment Optimization Guide

Overview

This skill covers scheduling optimization patterns for veterinary clinics, designed to maximize throughput while maintaining quality of care.


1. Slot Availability Algorithm

Time Slot Generation

// lib/scheduling/slot-generator.ts
interface TimeSlot {
  start: Date;
  end: Date;
  available: boolean;
  vetId?: string;
  serviceTypes: string[];
  priority: 'normal' | 'emergency' | 'buffer';
}

interface ScheduleConfig {
  businessHours: {
    [day: number]: { open: string; close: string; breaks?: { start: string; end: string }[] };
  };
  slotDuration: number; // minutes
  bufferBetweenSlots: number; // minutes
  emergencySlotRatio: number; // 0-1, percentage of slots reserved for emergencies
  maxAdvanceBookingDays: number;
}

const DEFAULT_CONFIG: ScheduleConfig = {
  businessHours: {
    1: { open: '08:00', close: '18:00', breaks: [{ start: '12:00', end: '14:00' }] }, // Monday
    2: { open: '08:00', close: '18:00', breaks: [{ start: '12:00', end: '14:00' }] },
    3: { open: '08:00', close: '18:00', breaks: [{ start: '12:00', end: '14:00' }] },
    4: { open: '08:00', close: '18:00', breaks: [{ start: '12:00', end: '14:00' }] },
    5: { open: '08:00', close: '18:00', breaks: [{ start: '12:00', end: '14:00' }] },
    6: { open: '08:00', close: '12:00' }, // Saturday
    // 0 (Sunday) not included = closed
  },
  slotDuration: 30,
  bufferBetweenSlots: 5,
  emergencySlotRatio: 0.1, // 10% reserved for emergencies
  maxAdvanceBookingDays: 30,
};

export function generateAvailableSlots(
  date: Date,
  vetId: string,
  existingAppointments: Appointment[],
  config: ScheduleConfig = DEFAULT_CONFIG
): TimeSlot[] {
  const dayOfWeek = date.getDay();
  const hours = config.businessHours[dayOfWeek];

  if (!hours) return []; // Closed this day

  const slots: TimeSlot[] = [];
  const [openHour, openMin] = hours.open.split(':').map(Number);
  const [closeHour, closeMin] = hours.close.split(':').map(Number);

  let current = new Date(date);
  current.setHours(openHour, openMin, 0, 0);

  const closeTime = new Date(date);
  closeTime.setHours(closeHour, closeMin, 0, 0);

  while (current < closeTime) {
    const slotEnd = new Date(current.getTime() + config.slotDuration * 60000);

    // Check if slot falls within a break
    const inBreak = hours.breaks?.some(breakPeriod => {
      const [breakStartH, breakStartM] = breakPeriod.start.split(':').map(Number);
      const [breakEndH, breakEndM] = breakPeriod.end.split(':').map(Number);
      const breakStart = new Date(date);
      breakStart.setHours(breakStartH, breakStartM, 0, 0);
      const breakEnd = new Date(date);
      breakEnd.setHours(breakEndH, breakEndM, 0, 0);
      return current >= breakStart && current < breakEnd;
    });

    if (!inBreak) {
      // Check if slot conflicts with existing appointments
      const hasConflict = existingAppointments.some(apt =>
        apt.vet_id === vetId &&
        new Date(apt.start_time) < slotEnd &&
        new Date(apt.end_time) > current
      );

      // Determine if this is an emergency-reserved slot
      const slotIndex = slots.length;
      const isEmergencySlot = slotIndex % Math.round(1 / config.emergencySlotRatio) === 0;

      slots.push({
        start: new Date(current),
        end: slotEnd,
        available: !hasConflict && (!isEmergencySlot || isWithinEmergencyWindow(current)),
        vetId,
        serviceTypes: ['general'], // Can be customized per vet
        priority: isEmergencySlot ? 'emergency' : 'normal',
      });
    }

    // Move to next slot (including buffer)
    current = new Date(current.getTime() + (config.slotDuration + config.bufferBetweenSlots) * 60000);
  }

  return slots;
}

// Emergency slots become available 2 hours before
function isWithinEmergencyWindow(slotTime: Date): boolean {
  const now = new Date();
  const hoursUntilSlot = (slotTime.getTime() - now.getTime()) / (1000 * 60 * 60);
  return hoursUntilSlot <= 2;
}

Optimal Slot Selection

// lib/scheduling/slot-optimizer.ts
interface SlotScore {
  slot: TimeSlot;
  score: number;
  reasons: string[];
}

interface OptimizationFactors {
  preferredTime?: 'morning' | 'afternoon' | 'any';
  urgency: 'routine' | 'soon' | 'urgent';
  serviceType: string;
  petSpecies: string;
  ownerHistory: {
    noShowRate: number;
    preferredTimes: string[];
    lastVisitDate?: Date;
  };
}

export function rankAvailableSlots(
  slots: TimeSlot[],
  factors: OptimizationFactors
): SlotScore[] {
  return slots
    .filter(slot => slot.available)
    .map(slot => {
      let score = 100;
      const reasons: string[] = [];

      // Time preference scoring
      const hour = slot.start.getHours();
      if (factors.preferredTime === 'morning' && hour >= 8 && hour < 12) {
        score += 20;
        reasons.push('Horario preferido (mañana)');
      } else if (factors.preferredTime === 'afternoon' && hour >= 14 && hour < 18) {
        score += 20;
        reasons.push('Horario preferido (tarde)');
      }

      // Urgency scoring - prioritize sooner slots
      if (factors.urgency === 'urgent') {
        const hoursUntil = (slot.start.getTime() - Date.now()) / (1000 * 60 * 60);
        score += Math.max(0, 50 - hoursUntil * 2); // More points for sooner slots
        reasons.push('Cita urgente - horario temprano');
      } else if (factors.urgency === 'soon') {
        const daysUntil = (slot.start.getTime() - Date.now()) / (1000 * 60 * 60 * 24);
        if (daysUntil <= 3) {
          score += 15;
          reasons.push('Disponibilidad próxima');
        }
      }

      // Owner history - avoid times with high no-show probability
      if (factors.ownerHistory.noShowRate > 0.2) {
        // Prefer slots that don't match their usual no-show pattern
        // (This would require more data analysis in practice)
        score -= 10;
        reasons.push('Ajuste por historial de asistencia');
      }

      // Slot efficiency - prefer slots that fill gaps
      if (isGapFiller(slot)) {
        score += 10;
        reasons.push('Optimiza horario del veterinario');
      }

      // Species-specific timing (e.g., cats prefer quieter times)
      if (factors.petSpecies === 'cat' && (hour === 8 || hour >= 16)) {
        score += 15;
        reasons.push('Horario tranquilo para gatos');
      }

      return { slot, score, reasons };
    })
    .sort((a, b) => b.score - a.score);
}

function isGapFiller(slot: TimeSlot): boolean {
  // Implementation would check surrounding appointments
  // to determine if this slot fills a gap efficiently
  return false;
}

2. Multi-Service Bundling

Service Bundle Detection

// lib/scheduling/bundling.ts
interface Service {
  id: string;
  name: string;
  duration: number;
  category: string;
  requiresVet: boolean;
  canBundleWith: string[]; // Service IDs
}

interface ServiceBundle {
  services: Service[];
  totalDuration: number;
  savings: number; // Time saved vs separate appointments
  suggestedOrder: string[]; // Service IDs in optimal order
}

const BUNDLE_RULES: Record<string, string[]> = {
  'consulta': ['vacuna', 'desparasitacion', 'microchip'],
  'vacuna': ['desparasitacion', 'consulta'],
  'peluqueria': ['bano', 'corte_unas'],
  'cirugia_esterilizacion': ['microchip', 'vacuna_antirrabica'],
  'limpieza_dental': ['extraccion_dental', 'consulta'],
};

export function suggestBundles(
  requestedService: Service,
  petHistory: PetHistory,
  availableServices: Service[]
): ServiceBundle[] {
  const bundles: ServiceBundle[] = [];

  // Check what services can be bundled with requested service
  const bundleable = BUNDLE_RULES[requestedService.id] || [];

  // Check pet's pending/due services
  const pendingServices = getPendingServices(petHistory);

  for (const serviceId of bundleable) {
    const service = availableServices.find(s => s.id === serviceId);
    if (!service) continue;

    // Check if pet needs this service
    const isPending = pendingServices.includes(serviceId);
    const isDue = isDueForService(petHistory, serviceId);

    if (isPending || isDue) {
      bundles.push({
        services: [requestedService, service],
        totalDuration: calculateBundleDuration([requestedService, service]),
        savings: calculateTimeSavings([requestedService, service]),
        suggestedOrder: determineBestOrder([requestedService, service]),
      });
    }
  }

  return bundles.sort((a, b) => b.savings - a.savings);
}

function calculateBundleDuration(services: Service[]): number {
  // Bundled services share prep time
  const baseDuration = services.reduce((sum, s) => sum + s.duration, 0);
  const prepTimeSaved = (services.length - 1) * 10; // 10 min prep per service
  return baseDuration - prepTimeSaved;
}

function calculateTimeSavings(services: Service[]): number {
  const separateTotal = services.reduce((sum, s) => sum + s.duration + 30, 0); // 30 min between appointments
  const bundledTotal = calculateBundleDuration(services);
  return separateTotal - bundledTotal;
}

function determineBestOrder(services: Service[]): string[] {
  // Order by: exam first, then procedures, then vaccines, then cosmetic
  const priority: Record<string, number> = {
    consulta: 1,
    examen: 1,
    cirugia: 2,
    limpieza_dental: 2,
    vacuna: 3,
    desparasitacion: 3,
    microchip: 4,
    peluqueria: 5,
    bano: 5,
  };

  return services
    .sort((a, b) => (priority[a.id] || 99) - (priority[b.id] || 99))
    .map(s => s.id);
}

function getPendingServices(history: PetHistory): string[] {
  // Check for overdue vaccines, pending procedures, etc.
  const pending: string[] = [];

  if (history.lastVaccineDate && daysSince(history.lastVaccineDate) > 365) {
    pending.push('vacuna');
  }

  if (history.lastDewormingDate && daysSince(history.lastDewormingDate) > 90) {
    pending.push('desparasitacion');
  }

  if (!history.hasMicrochip) {
    pending.push('microchip');
  }

  return pending;
}

function isDueForService(history: PetHistory, serviceId: string): boolean {
  // Check service-specific due dates
  switch (serviceId) {
    case 'limpieza_dental':
      return history.lastDentalCleaning && daysSince(history.lastDentalCleaning) > 365;
    case 'vacuna_antirrabica':
      return history.lastRabiesVaccine && daysSince(history.lastRabiesVaccine) > 365;
    default:
      return false;
  }
}

3. No-Show Prediction

Prediction Model

// lib/scheduling/no-show-prediction.ts
interface NoShowFactors {
  dayOfWeek: number;
  hourOfDay: number;
  daysUntilAppointment: number;
  previousNoShows: number;
  previousAppointments: number;
  appointmentType: string;
  weatherForecast?: 'sunny' | 'rainy' | 'stormy';
  isFirstVisit: boolean;
  remindersSent: number;
  confirmationReceived: boolean;
}

interface PredictionResult {
  probability: number; // 0-1
  riskLevel: 'low' | 'medium' | 'high';
  suggestedActions: string[];
}

// Simple logistic regression-style weights (would be trained on historical data)
const WEIGHTS = {
  intercept: -2.5,
  previousNoShowRate: 3.0,
  noConfirmation: 1.5,
  longWait: 0.05, // per day
  mondayMorning: 0.8,
  fridayAfternoon: 0.6,
  firstVisit: -0.5, // First visits less likely to no-show
  rainyWeather: 0.4,
  stormyWeather: 1.0,
  noReminders: 1.2,
  surgeryAppointment: -1.0, // Surgeries less likely to no-show
};

export function predictNoShow(factors: NoShowFactors): PredictionResult {
  let logit = WEIGHTS.intercept;

  // Previous no-show history
  if (factors.previousAppointments > 0) {
    const noShowRate = factors.previousNoShows / factors.previousAppointments;
    logit += WEIGHTS.previousNoShowRate * noShowRate;
  }

  // Confirmation status
  if (!factors.confirmationReceived) {
    logit += WEIGHTS.noConfirmation;
  }

  // Days until appointment (longer wait = higher no-show)
  logit += WEIGHTS.longWait * Math.max(0, factors.daysUntilAppointment - 3);

  // Day/time factors
  if (factors.dayOfWeek === 1 && factors.hourOfDay < 10) {
    logit += WEIGHTS.mondayMorning;
  }
  if (factors.dayOfWeek === 5 && factors.hourOfDay >= 15) {
    logit += WEIGHTS.fridayAfternoon;
  }

  // First visit
  if (factors.isFirstVisit) {
    logit += WEIGHTS.firstVisit;
  }

  // Weather
  if (factors.weatherForecast === 'rainy') {
    logit += WEIGHTS.rainyWeather;
  } else if (factors.weatherForecast === 'stormy') {
    logit += WEIGHTS.stormyWeather;
  }

  // Reminders
  if (factors.remindersSent === 0) {
    logit += WEIGHTS.noReminders;
  }

  // Appointment type
  if (factors.appointmentType === 'surgery') {
    logit += WEIGHTS.surgeryAppointment;
  }

  // Convert to probability
  const probability = 1 / (1 + Math.exp(-logit));

  // Determine risk level and actions
  let riskLevel: 'low' | 'medium' | 'high';
  const suggestedActions: string[] = [];

  if (probability < 0.15) {
    riskLevel = 'low';
  } else if (probability < 0.35) {
    riskLevel = 'medium';
    suggestedActions.push('Enviar recordatorio adicional');
    if (!factors.confirmationReceived) {
      suggestedActions.push('Solicitar confirmación por WhatsApp');
    }
  } else {
    riskLevel = 'high';
    suggestedActions.push('Llamar para confirmar');
    suggestedActions.push('Considerar overbooking para este horario');
    if (factors.daysUntilAppointment > 7) {
      suggestedActions.push('Enviar múltiples recordatorios');
    }
  }

  return { probability, riskLevel, suggestedActions };
}

Overbooking Strategy

// lib/scheduling/overbooking.ts
interface OverbookingDecision {
  allowOverbook: boolean;
  maxOverbooks: number;
  reason: string;
}

export function shouldAllowOverbooking(
  slot: TimeSlot,
  existingAppointments: Appointment[],
  historicalNoShowRate: number
): OverbookingDecision {
  // Calculate expected no-shows for existing appointments
  const noShowPredictions = existingAppointments.map(apt =>
    predictNoShow(getFactorsFromAppointment(apt))
  );

  const expectedNoShows = noShowPredictions.reduce(
    (sum, pred) => sum + pred.probability,
    0
  );

  // If expected no-shows >= 1, allow overbooking
  if (expectedNoShows >= 0.8) {
    return {
      allowOverbook: true,
      maxOverbooks: Math.floor(expectedNoShows),
      reason: `${Math.round(expectedNoShows * 100)}% probabilidad de ausencias`,
    };
  }

  // Check historical rate for this time slot
  if (historicalNoShowRate > 0.25) {
    return {
      allowOverbook: true,
      maxOverbooks: 1,
      reason: `Históricamente ${Math.round(historicalNoShowRate * 100)}% de ausencias en este horario`,
    };
  }

  return {
    allowOverbook: false,
    maxOverbooks: 0,
    reason: 'Bajo riesgo de ausencias',
  };
}

4. Emergency Slot Management

Dynamic Emergency Reservation

// lib/scheduling/emergency-slots.ts
interface EmergencyConfig {
  minReservedSlotsPerDay: number;
  reservationHoursAhead: number;
  releaseIfUnusedHours: number;
  emergencyCategories: string[];
}

const EMERGENCY_CONFIG: EmergencyConfig = {
  minReservedSlotsPerDay: 2,
  reservationHoursAhead: 24, // Reserve slots 24h ahead
  releaseIfUnusedHours: 2, // Release to regular booking 2h before
  emergencyCategories: [
    'trauma',
    'dificultad_respiratoria',
    'intoxicacion',
    'convulsiones',
    'sangrado',
    'parto',
  ],
};

export function manageEmergencySlots(
  date: Date,
  allSlots: TimeSlot[],
  currentEmergencies: number,
  config: EmergencyConfig = EMERGENCY_CONFIG
): TimeSlot[] {
  const now = new Date();
  const hoursUntilDate = (date.getTime() - now.getTime()) / (1000 * 60 * 60);

  return allSlots.map(slot => {
    if (slot.priority !== 'emergency') return slot;

    const hoursUntilSlot = (slot.start.getTime() - now.getTime()) / (1000 * 60 * 60);

    // Release emergency slots if:
    // 1. Less than releaseIfUnusedHours before slot time
    // 2. We have enough emergency capacity with current emergencies
    if (hoursUntilSlot <= config.releaseIfUnusedHours) {
      return {
        ...slot,
        available: true,
        priority: 'normal' as const,
      };
    }

    // Keep reserved if within reservation window
    if (hoursUntilSlot <= config.reservationHoursAhead) {
      return {
        ...slot,
        available: false, // Reserved for emergencies only
      };
    }

    return slot;
  });
}

export function findEmergencySlot(
  emergencyType: string,
  availableSlots: TimeSlot[]
): TimeSlot | null {
  // Find the next available slot (emergency or released)
  const now = new Date();

  // First, try emergency-reserved slots
  const emergencySlot = availableSlots.find(
    slot => slot.priority === 'emergency' && slot.start > now
  );

  if (emergencySlot) return emergencySlot;

  // If no emergency slots, find any available slot
  return availableSlots.find(
    slot => slot.available && slot.start > now
  ) || null;
}

5. Wait Time Estimation

// lib/scheduling/wait-time.ts
interface WaitTimeEstimate {
  estimatedMinutes: number;
  confidenceLevel: 'high' | 'medium' | 'low';
  factors: string[];
}

export function estimateWaitTime(
  appointmentTime: Date,
  currentQueue: Appointment[],
  historicalData: {
    avgDelayMinutes: number;
    stdDevMinutes: number;
  }
): WaitTimeEstimate {
  const now = new Date();
  const scheduledMinutesFromNow = (appointmentTime.getTime() - now.getTime()) / 60000;

  // Count appointments before this one today
  const appointmentsBefore = currentQueue.filter(apt =>
    new Date(apt.start_time) < appointmentTime &&
    new Date(apt.start_time).toDateString() === appointmentTime.toDateString()
  );

  // Calculate cumulative delay potential
  let estimatedDelay = 0;
  const factors: string[] = [];

  // Historical average delay
  estimatedDelay += historicalData.avgDelayMinutes;
  factors.push(`Demora promedio histórica: ${historicalData.avgDelayMinutes} min`);

  // Add delay for each appointment before (assuming some run over)
  const delayPerPriorAppointment = 3; // minutes
  const priorDelays = appointmentsBefore.length * delayPerPriorAppointment * 0.3; // 30% chance each runs over
  estimatedDelay += priorDelays;
  if (priorDelays > 0) {
    factors.push(`${appointmentsBefore.length} citas previas pueden causar demora`);
  }

  // Check for known delays (e.g., emergency in progress)
  const emergencyInProgress = currentQueue.some(apt =>
    apt.appointment_type === 'emergency' && apt.status === 'in_progress'
  );
  if (emergencyInProgress) {
    estimatedDelay += 20;
    factors.push('Emergencia en atención - posible demora adicional');
  }

  // Confidence level based on data quality
  let confidenceLevel: 'high' | 'medium' | 'low' = 'medium';
  if (historicalData.stdDevMinutes < 10) {
    confidenceLevel = 'high';
  } else if (historicalData.stdDevMinutes > 20) {
    confidenceLevel = 'low';
  }

  return {
    estimatedMinutes: Math.max(0, Math.round(estimatedDelay)),
    confidenceLevel,
    factors,
  };
}

6. Database Functions

-- Get optimal appointment slots
CREATE OR REPLACE FUNCTION get_optimal_slots(
  p_tenant_id TEXT,
  p_service_id UUID,
  p_date DATE,
  p_preferred_time TEXT DEFAULT 'any'
)
RETURNS TABLE (
  slot_start TIMESTAMPTZ,
  slot_end TIMESTAMPTZ,
  vet_id UUID,
  vet_name TEXT,
  score INTEGER
)
LANGUAGE plpgsql
AS $$
BEGIN
  RETURN QUERY
  WITH available_slots AS (
    -- Generate time slots
    SELECT
      gs.slot_time AS start_time,
      gs.slot_time + (s.duration_minutes || ' minutes')::INTERVAL AS end_time,
      ss.staff_id AS vet_id
    FROM generate_series(
      p_date + '08:00:00'::TIME,
      p_date + '17:30:00'::TIME,
      '30 minutes'::INTERVAL
    ) AS gs(slot_time)
    CROSS JOIN services s
    CROSS JOIN staff_schedules ss
    WHERE s.id = p_service_id
    AND ss.tenant_id = p_tenant_id
    AND ss.day_of_week = EXTRACT(DOW FROM p_date)
    AND gs.slot_time::TIME >= ss.start_time
    AND (gs.slot_time + (s.duration_minutes || ' minutes')::INTERVAL)::TIME <= ss.end_time
    -- Exclude existing appointments
    AND NOT EXISTS (
      SELECT 1 FROM appointments a
      WHERE a.vet_id = ss.staff_id
      AND a.start_time < gs.slot_time + (s.duration_minutes || ' minutes')::INTERVAL
      AND a.end_time > gs.slot_time
      AND a.status NOT IN ('cancelled', 'no_show')
    )
  )
  SELECT
    av.start_time,
    av.end_time,
    av.vet_id,
    p.full_name,
    -- Scoring
    CASE
      WHEN p_preferred_time = 'morning' AND EXTRACT(HOUR FROM av.start_time) < 12 THEN 20
      WHEN p_preferred_time = 'afternoon' AND EXTRACT(HOUR FROM av.start_time) >= 14 THEN 20
      ELSE 0
    END +
    -- Prefer slots that fill gaps
    CASE WHEN EXISTS (
      SELECT 1 FROM appointments a
      WHERE a.vet_id = av.vet_id
      AND DATE(a.start_time) = p_date
      AND (
        a.end_time = av.start_time
        OR a.start_time = av.end_time
      )
    ) THEN 10 ELSE 0 END AS score
  FROM available_slots av
  JOIN profiles p ON p.id = av.vet_id
  ORDER BY score DESC, av.start_time
  LIMIT 20;
END;
$$;

Reference: Healthcare scheduling optimization research, veterinary practice management best practices