import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Appointment, Encounter } from 'fhir/r4'; import { useEncounterServiceContext, type ReadInitiator } from '../../../services/EncounterService'; import { useFhirServiceContext } from '../../../services/FhirService'; import { buildQueue, type QueueItem } from '../ClinicianOverviewPage/encounterLifecycle'; import { applyById, encounterBelongsToDay, DAY_FALLBACK_READ_MS, type DayEvent } from '../../../utils/liveDayEvents'; import { useLiveDayEvents } from '../../../hooks/useLiveDayEvents'; import { appointmentsNeedingEncounters, encountersForAppointment, frontDeskBoardAppointments, } from './frontDeskDay'; /** * - the React half of the front desk's day. The scope rules it obeys are pure and live * in `frontDeskDay.ts`; this file is the fan-out, the staleness overlay and the refresh clock. */ /** * How often the encounters are re-read. Encounter broadcasts move the board; this read only catches an * event the socket missed. A check-in this client performs appears at once through the overlay below. */ export const FRONT_DESK_ENCOUNTER_REFRESH_MS = DAY_FALLBACK_READ_MS; /** * overlay, same reasoning as the clinician board: HealthLake search is roughly 30 seconds * stale after a write, so a receptionist who marks a patient arrived would watch the row not move. The * client already holds the authoritative answer - `createEncounter`/`updateEncounter` RETURN the written * resource - and an entry retires as soon as the server agrees, or unconditionally after this age, so the * worst case is that the board is briefly optimistic and never permanently wrong. */ const OVERLAY_MAX_AGE_MS = 60_000; export interface FrontDeskDay { /** The merged appointment+encounter queue the four front-desk queues re-bucket. */ items: QueueItem[]; loadingEncounters: boolean; /** A read has come back at least once; later reads refresh a board that is already showing. Nothing * to read is not an answer: before the appointments arrive there is always nothing to read. */ encountersAnswered: boolean; /** * A failed encounter read is REPORTED, never swallowed into "nobody is here". Without this the * queues degrade silently to appointment-only states and an occupied waiting room renders as an empty * one - the shape of false-clean this project keeps getting bitten by. */ encounterError?: string; /** Tell the board about an encounter this client just wrote. Pass the resource the API RETURNED. */ noteLocalEncounter: (encounter: Encounter) => void; refreshEncounters: () => void; } export const useFrontDeskDay = ( appointments: readonly Appointment[], now: number, ): FrontDeskDay => { const { fetchEncounters } = useEncounterServiceContext(); const { unpackBundle } = useFhirServiceContext(); const [encounters, setEncounters] = useState([]); const [loadingEncounters, setLoadingEncounters] = useState(false); const [encountersAnswered, setEncountersAnswered] = useState(false); const [encounterError, setEncounterError] = useState(); /** * THE TICK CARRIES ITS OWN REASON, and a plain counter could not. * * Two different things bump this: the sixty-second timer below, and the window `focus` listener * beside it. A person coming back to the tab IS asking for fresh data; a timer firing at 3am is not. * `tick > 0` would have called both of them background and put a false label on a real read - the * mirror image of the defect this exists to fix. */ const [tick, setTick] = useState<{ n: number; initiator: ReadInitiator }>({ n: 0, initiator: 'user' }); const [localEncounters, setLocalEncounters] = useState>([]); const runToken = useRef(0); // Loading is for a board with nothing on it yet; once it has answered, a re-read refreshes it in place. const answered = useRef(false); const refreshEncounters = useCallback( () => setTick((value) => ({ n: value.n + 1, initiator: 'user' })), [], ); /** The unattended one. Only the interval calls this. */ const pollEncounters = useCallback( () => setTick((value) => ({ n: value.n + 1, initiator: 'background' })), [], ); const noteLocalEncounter = useCallback((encounter: Encounter) => { if (!encounter?.id) return; setLocalEncounters((previous) => [ ...previous.filter((entry) => entry.encounter.id !== encounter.id), { encounter, atMs: Date.now() }, ]); }, []); const boardAppointments = useMemo( () => frontDeskBoardAppointments(appointments, now), // `now` is deliberately not a dependency: it advances every 30 seconds, and re-deriving the board // on each tick would re-fire the per-appointment fan-out twice a minute. The only thing it decides // here is the local-day boundary, which moves once a day and is crossed within one refresh interval. // eslint-disable-next-line react-hooks/exhaustive-deps [appointments], ); // Only the IDS are a dependency. `boardAppointments` is a fresh array on every parent render, and // depending on it directly would re-run the fan-out on each one. const encounterAppointmentIds = useMemo( () => appointmentsNeedingEncounters(boardAppointments, Date.now()) .map((appointment) => appointment.id) .filter((id): id is string => Boolean(id)) .join(','), [boardAppointments], ); useEffect(() => { const ids = encounterAppointmentIds ? encounterAppointmentIds.split(',') : []; if (ids.length === 0) { runToken.current++; setEncounters([]); setEncounterError(undefined); setLoadingEncounters(false); return; } const token = ++runToken.current; const byId = new Map(boardAppointments.map((appointment) => [appointment.id, appointment])); if (!answered.current) setLoadingEncounters(true); setEncounterError(undefined); // Failure-isolated per appointment: one refused lookup leaves that row appointment-only rather than // blanking the whole board. But the failures are COUNTED, so a wholesale failure is said aloud // instead of rendering as a quiet morning. void Promise.all( ids.map((id) => { const appointment = byId.get(id); if (!appointment) return Promise.resolve([]); return fetchEncounters({ appointment, initiator: tick.initiator }) .then((bundle) => encountersForAppointment(id, unpackBundle(bundle) as Encounter[])) .catch(() => undefined); }), ).then((results) => { if (token !== runToken.current) return; const failed = results.filter((result) => result === undefined).length; setEncounters(results.flatMap((result) => result ?? [])); setEncounterError( failed === 0 ? undefined : `Could not read the visit status for ${failed} of ${ids.length} of today's appointments.`, ); setLoadingEncounters(false); setEncountersAnswered(true); answered.current = true; }); }, [encounterAppointmentIds, boardAppointments, fetchEncounters, unpackBundle, tick]); const shown = useRef({ encounterAppointmentIds, encounters }); shown.current = { encounterAppointmentIds, encounters }; // A broadcast carries the written encounter, so it is applied as it stands and put on the overlay, where // a search that has not caught up cannot undo it. const applyDayEvent = useCallback((event: DayEvent) => { if (event.kind !== 'encounter') return; const encounter = event.resource; const ids = new Set(shown.current.encounterAppointmentIds ? shown.current.encounterAppointmentIds.split(',') : []); const kept = !event.removed && encounterBelongsToDay(encounter, ids, shown.current.encounters); setEncounters((previous) => applyById(previous, encounter, kept)); setLocalEncounters((previous) => { const rest = previous.filter((entry) => entry.encounter.id !== encounter.id); return kept ? [...rest, { encounter, atMs: Date.now() }] : rest; }); }, []); useLiveDayEvents(applyDayEvent, pollEncounters); useEffect(() => { const interval = window.setInterval(pollEncounters, FRONT_DESK_ENCOUNTER_REFRESH_MS); window.addEventListener('focus', refreshEncounters); return () => { window.clearInterval(interval); window.removeEventListener('focus', refreshEncounters); }; }, [refreshEncounters, pollEncounters]); const items = useMemo(() => { const live = localEncounters.filter(({ encounter, atMs }) => { if (Date.now() - atMs > OVERLAY_MAX_AGE_MS) return false; const server = encounters.find((candidate) => candidate.id === encounter.id); return !server || server.status !== encounter.status; }); const overlaid = new Map(encounters.map((encounter) => [encounter.id, encounter])); for (const { encounter } of live) overlaid.set(encounter.id, encounter); return buildQueue({ appointments: [...boardAppointments], encounters: [...overlaid.values()], }); }, [boardAppointments, encounters, localEncounters]); return { items, loadingEncounters, encountersAnswered, encounterError, noteLocalEncounter, refreshEncounters }; };