import { useEffect, useRef, useState } from 'react'; import type { Appointment, Bundle } from 'fhir/r4'; import { useOptionalWebSocketServiceContext } from './WebSocketService.tsx'; import { useRereadOnReturn } from '../hooks/useRereadOnReturn'; import { onViewerChange, viewerEpoch } from '../utils/writeThroughHold'; const APPOINTMENT_CHANGED = ['created', 'updated', 'deleted'].map((action) => `com.akashichealthrecords.fhir.appointment.${action}`); export const INVITATION_CHANGED = 'com.akashichealthrecords.fhir.visitinvitation.changed'; // Sent to the patient and their helpers in whatever practice each session is in: ids only. export const APPOINTMENT_NOTICE = 'appointment.changed'; // Notices this close together are read back as one. export const FOLLOW_COALESCE_MS = 250; export interface FollowedAppointments { appointmentsList: Appointment[]; cachedForPatientId: string | undefined; holdServerChangedAppointments: (ids: string[], transport?: { admit: () => void; signal: AbortSignal }) => Promise; rereadAfterAbsence: (missed?: boolean) => void; } const isPatients = (appointment: Appointment, patientId: string): boolean => appointment.participant?.some((participant) => participant.actor?.reference === `Patient/${patientId}`) ?? false; class FollowRetiredError extends Error {} // A change made in another session reaches the list this patient already has. A notice only names a visit to read // again by id; what it carried is never shown. Where no notice can, coming back or reconnecting reads the list again. export const useFollowAppointmentChanges = (service: FollowedAppointments, patientId: string | undefined): void => { const socket = useOptionalWebSocketServiceContext(); const latest = useRef(service); latest.current = service; const missed = useRef(false); const subscribe = socket?.subscribe; const unsubscribe = socket?.unsubscribe; // A new viewer, practice or credential starts a new follow; the old one's queued and in-flight work is retired. const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); useEffect(() => { if (!subscribe || !unsubscribe || !patientId) return; let retired = false; const startedFor = viewerEpoch(); // Checked against the viewer as it is now, before any render has caught up with a change. const owned = () => !retired && viewerEpoch() === startedFor; let timer: ReturnType | undefined; const controller = new AbortController(); const pending = new Set(); const reading = new Set(); const readBack = () => { timer = undefined; const ids = [...pending]; pending.clear(); if (!owned()) return; // Work for a hidden page waits for the return, which reads the whole list. if (document.visibilityState === 'hidden') { missed.current = true; return; } ids.forEach((id) => reading.add(id)); const transport = { admit: () => { if (!owned()) throw new FollowRetiredError(); }, signal: controller.signal }; latest.current.holdServerChangedAppointments(ids, transport) .catch(() => { // Unread is not unchanged: the list stays stale until it is searched again. if (owned()) latest.current.rereadAfterAbsence(true); }) .finally(() => ids.forEach((id) => reading.delete(id))); }; const follow = (id: string | undefined) => { if (!id || !owned()) return; pending.add(id); timer ??= setTimeout(readBack, FOLLOW_COALESCE_MS); }; const onAppointment = (event: { data?: Bundle }) => { const appointment = event?.data?.entry?.find((entry) => entry.resource?.resourceType === 'Appointment')?.resource; if (appointment && isPatients(appointment, patientId)) follow(appointment.id); }; const onNotice = (data: { appointmentId?: unknown; patientId?: unknown; revision?: unknown } | undefined) => { if (data?.patientId !== patientId || typeof data.appointmentId !== 'string') return; const current = latest.current; const known = current.cachedForPatientId === patientId ? current.appointmentsList.find((appointment) => appointment.id === data.appointmentId) : undefined; if (known && Number(known.meta?.versionId) >= Number(data.revision)) return; follow(data.appointmentId); }; const onInvitation = (event: { data?: { appointmentId?: unknown; revision?: unknown } }) => { const id = event?.data?.appointmentId; if (typeof id !== 'string') return; // Only a visit this patient's list has, or is reading now; one already as new as the change needs no read. const current = latest.current; const known = current.cachedForPatientId === patientId ? current.appointmentsList.find((appointment) => appointment.id === id) : undefined; if (!known && !pending.has(id) && !reading.has(id)) return; const revision = Number(event.data?.revision); const have = Number(known?.meta?.versionId); if (known && Number.isFinite(revision) && Number.isFinite(have) && have >= revision) return; follow(id); }; APPOINTMENT_CHANGED.forEach((type) => subscribe(type, onAppointment)); subscribe(INVITATION_CHANGED, onInvitation); subscribe(APPOINTMENT_NOTICE, onNotice); return () => { retired = true; controller.abort(); if (timer) clearTimeout(timer); APPOINTMENT_CHANGED.forEach((type) => unsubscribe(type, onAppointment)); unsubscribe(INVITATION_CHANGED, onInvitation); unsubscribe(APPOINTMENT_NOTICE, onNotice); }; }, [subscribe, unsubscribe, patientId, viewer]); // A socket that dropped and came back may have missed notices while it was down; its first connection has not. const connected = socket?.isConnected; const seen = useRef<'never' | 'up' | 'down'>('never'); useEffect(() => { if (connected) { if (seen.current === 'down') latest.current.rereadAfterAbsence(true); seen.current = 'up'; } else if (seen.current === 'up') { seen.current = 'down'; } }, [connected]); useRereadOnReturn(() => { const wasMissed = missed.current; missed.current = false; latest.current.rereadAfterAbsence(wasMissed); }, Boolean(patientId)); };