/** * WebSocket hook for real-time appointment updates. * Connects to the backend WebSocket and updates React Query cache. */ import { useEffect, useRef, useCallback, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { getCookie } from '../utils/cookies'; import { getSubdomain } from '../api/config'; import { Appointment } from '../types'; interface WebSocketMessage { type: 'connection_established' | 'appointment_created' | 'appointment_updated' | 'appointment_deleted' | 'pong'; appointment?: { id: string; business_id: string; service_id: string; resource_id: string | null; customer_id: string; customer_name: string; start_time: string; end_time: string; duration_minutes: number; status: string; notes: string; }; appointment_id?: string; message?: string; } interface UseAppointmentWebSocketOptions { enabled?: boolean; onConnected?: () => void; onDisconnected?: () => void; onError?: (error: Event) => void; } /** * Transform backend appointment format to frontend format */ function transformAppointment(data: WebSocketMessage['appointment']): Appointment | null { if (!data) return null; return { id: data.id, resourceId: data.resource_id, customerId: data.customer_id, customerName: data.customer_name, serviceId: data.service_id, startTime: new Date(data.start_time), durationMinutes: data.duration_minutes, status: data.status as Appointment['status'], notes: data.notes, }; } /** * Hook for real-time appointment updates via WebSocket. * Handles React StrictMode's double-effect invocation gracefully. */ export function useAppointmentWebSocket(options: UseAppointmentWebSocketOptions = {}) { const { enabled = true, onConnected, onDisconnected, onError } = options; const queryClient = useQueryClient(); const wsRef = useRef(null); const reconnectTimeoutRef = useRef(null); const pingIntervalRef = useRef(null); const reconnectAttemptsRef = useRef(0); const isCleaningUpRef = useRef(false); const maxReconnectAttempts = 5; const [isConnected, setIsConnected] = useState(false); // Store callbacks in refs to avoid effect re-runs const onConnectedRef = useRef(onConnected); const onDisconnectedRef = useRef(onDisconnected); const onErrorRef = useRef(onError); useEffect(() => { onConnectedRef.current = onConnected; onDisconnectedRef.current = onDisconnected; onErrorRef.current = onError; }, [onConnected, onDisconnected, onError]); // Get WebSocket URL - not a callback to avoid recreating const getWebSocketUrl = () => { const token = getCookie('access_token'); const subdomain = getSubdomain(); if (!token || !subdomain) { return null; } // Determine WebSocket host - use api subdomain for WebSocket const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsHost = `api.lvh.me:8000`; // In production, this would come from config return `${wsProtocol}//${wsHost}/ws/appointments/?token=${token}&subdomain=${subdomain}`; }; const updateQueryCache = useCallback((message: WebSocketMessage) => { const queryCache = queryClient.getQueryCache(); const appointmentQueries = queryCache.findAll({ queryKey: ['appointments'] }); appointmentQueries.forEach((query) => { queryClient.setQueryData(query.queryKey, (old) => { if (!old) return old; switch (message.type) { case 'appointment_created': { const newAppointment = transformAppointment(message.appointment); if (!newAppointment) return old; // Check if appointment already exists (avoid duplicates) if (old.some(apt => apt.id === newAppointment.id)) { return old; } return [...old, newAppointment]; } case 'appointment_updated': { const updatedAppointment = transformAppointment(message.appointment); if (!updatedAppointment) return old; return old.map(apt => apt.id === updatedAppointment.id ? updatedAppointment : apt ); } case 'appointment_deleted': { if (!message.appointment_id) return old; return old.filter(apt => apt.id !== message.appointment_id); } default: return old; } }); }); }, [queryClient]); // Main effect to manage WebSocket connection // Only depends on `enabled` - other values are read from refs or called as functions useEffect(() => { if (!enabled) { return; } // Reset cleanup flag at start of effect isCleaningUpRef.current = false; // Track the current effect's abort controller to handle StrictMode let effectAborted = false; const connect = () => { // Don't connect if effect was aborted or we're cleaning up if (effectAborted || isCleaningUpRef.current) { return; } const url = getWebSocketUrl(); if (!url) { console.log('WebSocket: Missing token or subdomain, skipping connection'); return; } // Close existing connection if any if (wsRef.current && wsRef.current.readyState !== WebSocket.CLOSED) { wsRef.current.close(); } console.log('WebSocket: Connecting to', url.replace(/token=[^&]+/, 'token=***')); const ws = new WebSocket(url); ws.onopen = () => { // Don't process if effect was aborted or cleaning up if (effectAborted || isCleaningUpRef.current) { ws.close(); return; } console.log('WebSocket: Connected'); reconnectAttemptsRef.current = 0; setIsConnected(true); onConnectedRef.current?.(); // Start ping interval to keep connection alive if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); } pingIntervalRef.current = setInterval(() => { if (ws.readyState === WebSocket.OPEN && !effectAborted) { ws.send(JSON.stringify({ type: 'ping' })); } }, 30000); // Ping every 30 seconds }; ws.onmessage = (event) => { // Ignore messages if effect was aborted if (effectAborted) return; try { const message: WebSocketMessage = JSON.parse(event.data); switch (message.type) { case 'connection_established': console.log('WebSocket: Connection confirmed -', message.message); break; case 'pong': // Heartbeat response, ignore break; case 'appointment_created': case 'appointment_updated': case 'appointment_deleted': console.log('WebSocket: Received', message.type); updateQueryCache(message); break; default: console.log('WebSocket: Unknown message type', message); } } catch (err) { console.error('WebSocket: Failed to parse message', err); } }; ws.onerror = (error) => { // Only log error if not aborted (StrictMode cleanup causes expected errors) if (!effectAborted) { console.error('WebSocket: Error', error); onErrorRef.current?.(error); } }; ws.onclose = (event) => { // Don't log or handle if effect was aborted (expected during StrictMode) if (effectAborted) { return; } console.log('WebSocket: Disconnected', event.code, event.reason); setIsConnected(false); onDisconnectedRef.current?.(); // Clear ping interval if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } // Only attempt reconnection if not cleaning up if (!isCleaningUpRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); console.log(`WebSocket: Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`); reconnectTimeoutRef.current = setTimeout(() => { reconnectAttemptsRef.current++; connect(); }, delay); } }; wsRef.current = ws; }; connect(); // Cleanup function return () => { effectAborted = true; isCleaningUpRef.current = true; // Clear reconnect timeout if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } // Clear ping interval if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } // Close WebSocket if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } setIsConnected(false); }; }, [enabled]); // Only re-run when enabled changes const reconnect = useCallback(() => { isCleaningUpRef.current = false; reconnectAttemptsRef.current = 0; // Close existing connection if (wsRef.current) { wsRef.current.close(); } // Connection will be re-established by the effect when we force re-render // For now, we'll rely on the onclose handler to trigger reconnection }, []); const disconnect = useCallback(() => { isCleaningUpRef.current = true; // Clear reconnect timeout if (reconnectTimeoutRef.current) { clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } // Clear ping interval if (pingIntervalRef.current) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } // Close WebSocket if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } setIsConnected(false); }, []); return { isConnected, reconnect, disconnect, }; }