import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { Appointment, Encounter } from 'fhir/r4'; import { useAuthServiceContext } from '../../../services/AuthService'; import { useAppointmentServiceContext } from '../../../services/AppointmentService'; import { useEncounterServiceContext, type ReadInitiator } from '../../../services/EncounterService'; import { useFhirServiceContext } from '../../../services/FhirService'; import { getLocalDayRange } from '../../../utils/localDayRange'; import { recentCheckInIds } from '../../../utils/recentCheckIns'; import { rememberVisitRecords } from '../../../utils/knownVisitRecords'; import { describeFetchFailure, isRetryableFailure } from '../../../utils/fetchFailureMessage'; import { applyById, encounterBelongsToDay, DAY_FALLBACK_READ_MS, type DayEvent } from '../../../utils/liveDayEvents'; import { useLiveDayEvents } from '../../../hooks/useLiveDayEvents'; import { buildQueue, practitionerReferenceSet, appointmentMatchesPractitioner, appointmentAtLocations, checkInsToRead, type QueueItem, } from './encounterLifecycle'; // Broadcasts carry the day; this read only catches an event the socket missed. const REFRESH_MS = DAY_FALLBACK_READ_MS; // Appointments in these statuses aren't happening today - keep them out of the queue. const EXCLUDED_APPOINTMENT_STATUSES = new Set(['cancelled', 'noshow', 'entered-in-error']); /** * (design-pass finding B47) - WHY THE BOARD NEEDS TO BE TOLD, NOT JUST REFRESHED. * * Pressing `Start encounter` produced NO visible change: the route stayed put, the patient stayed under * `LATER TODAY`, `IN PROGRESS` stayed 0 - while the encounter HAD been created server-side. Navigating * away and back rendered correctly. * * The action already called `onChanged` (this hook's `refresh`) in its `finally`. The refresh is not the * problem - what it reads is. `refresh` re-runs an Appointment SEARCH and a per-appointment Encounter * SEARCH, and **HealthLake is eventually consistent: a search is roughly 30s stale after a write** * reference-healthlake-search-traps, whose rule is exactly "verify writes by read-by-id, never by * search". So the refetch faithfully returned the PRE-WRITE world and the board redrew itself unchanged. * * The client already holds the authoritative answer - `createEncounter`/`updateEncounter` RETURN the * written resource. This overlay applies what we know on top of what the server has caught up to, and * retires itself the moment the server agrees. It is not an optimistic guess: nothing is displayed that * was not confirmed written by the API's own response. * * THE SAFETY VALVE IS DELIBERATE. An overlay that never expires would mask a genuine server-side * change (someone else finishing the encounter). Entries are dropped as soon as a fetch reports the same * id at the same status, and unconditionally after OVERLAY_MAX_AGE_MS regardless - so the worst case is * that the board is briefly optimistic, never permanently wrong. */ const OVERLAY_MAX_AGE_MS = 60_000; /** Overlay entries the server has not yet echoed back at the same status, and that have not aged out. */ const liveOverlay = ( local: Array<{ encounter: Encounter; atMs: number }>, fetched: Encounter[], now: number, ) => local.filter(({ encounter, atMs }) => { if (now - atMs > OVERLAY_MAX_AGE_MS) return false; const server = fetched.find((f) => f.id === encounter.id); return !server || server.status !== encounter.status; }); export interface ClinicianDay { queue: QueueItem[]; loading: boolean; error: string | undefined; /** * (B46) - is `error` worth offering a Retry for? A 403 explicit deny is permanent, and a * Retry button on one is an affordance guaranteed to fail on every press. Defaults true, so an * ordinary fault keeps its retry. */ errorIsRetryable: boolean; refresh: () => void; /** * Tell the board about an encounter this client just wrote, so the queue reflects it immediately * instead of waiting for an eventually-consistent search to catch up. Pass the resource the API * RETURNED, never a locally-assembled guess. */ noteLocalEncounter: (encounter: Encounter) => void; } /** Seeing patients is the user's own day; rooming is the practice's (see `appointmentAtLocations`). */ export type DayScope = 'own' | 'practice'; export const useClinicianDay = (scope: DayScope = 'own'): ClinicianDay => { const { currentUser, practitionerRoles } = useAuthServiceContext(); const { fetchTodaysAppointmentsForOrg } = useAppointmentServiceContext(); const { fetchEncounters, fetchEncounter } = useEncounterServiceContext(); // Its identity follows the service's cached list; the board should not refetch when a chart loads. const fetchEncounterRef = useRef(fetchEncounter); fetchEncounterRef.current = fetchEncounter; const { unpackBundle } = useFhirServiceContext(); const [appointments, setAppointments] = useState([]); const [encounters, setEncounters] = useState([]); const [loading, setLoading] = useState(false); // The day (whose, which roles, where) last answered. A re-read of it refreshes what is on screen // instead of swapping it for "Loading your day…", which flashed the board every minute. const [answeredDay, setAnsweredDay] = useState(); const [error, setError] = useState(); // Whether the CURRENT error is worth offering a Retry for. Defaults true so a transient fault keeps // its retry - failing open is the safe direction here (see fetchFailureMessage.ts). const [errorIsRetryable, setErrorIsRetryable] = useState(true); /** Carries its own reason - see the note in useFrontDeskDay. A retry the clinician pressed is a * human read; the sixty-second timer is not, and the audit row now says which. */ const [tick, setTick] = useState<{ n: number; initiator: ReadInitiator }>({ n: 0, initiator: 'user' }); const runToken = useRef(0); // Encounters this client has written and the server has not yet echoed back. Keyed by encounter id. const [localEncounters, setLocalEncounters] = useState>([]); const noteLocalEncounter = useCallback((encounter: Encounter) => { if (!encounter?.id) return; setLocalEncounters((prev) => [ ...prev.filter((e) => e.encounter.id !== encounter.id), { encounter, atMs: Date.now() }, ]); }, []); const practitionerId = currentUser?.fhir?.id; const roleIds = (practitionerRoles ?? []) .map((r) => r.id) .filter((id): id is string => Boolean(id)) .join(','); const locationIds = (practitionerRoles ?? []) .flatMap((r) => r.location ?? []) .map((l) => l.reference) .filter((ref): ref is string => Boolean(ref)) .join(','); /** Today's visits for this board: not cancelled, the user's own (or at their sites), inside today. */ const keepAppointment = useCallback((a: Appointment): boolean => { if (!practitionerId || EXCLUDED_APPOINTMENT_STATUSES.has(a.status) || !a.start) return false; const inScope = scope === 'practice' ? appointmentAtLocations(a, new Set(locationIds ? locationIds.split(',') : [])) : appointmentMatchesPractitioner(a, practitionerReferenceSet(practitionerId, roleIds ? roleIds.split(',') : [])); if (!inScope) return false; // Defense-in-depth day guard (mirrors ReceptionistHomePage): an out-of-window appointment never // surfaces as "later today", even if the search or a broadcast returns one. const { start, end } = getLocalDayRange(); const t = new Date(a.start).getTime(); return t >= start.getTime() && t < end.getTime(); }, [practitionerId, roleIds, locationIds, scope]); const refresh = useCallback(() => setTick((t) => ({ n: t.n + 1, initiator: 'user' })), []); /** The unattended one. Only the interval calls this. */ const poll = useCallback(() => setTick((t) => ({ n: t.n + 1, initiator: 'background' })), []); useEffect(() => { // Reload-loop safety: never fetch before the user identity is resolved. // Bump the run token so any in-flight fetch is invalidated when identity is lost // (logout/auth reset) and cannot repopulate the queue or flip loading back on. if (!practitionerId) { runToken.current++; setAppointments([]); setEncounters([]); return; } const token = ++runToken.current; const dayKey = [practitionerId, roleIds, locationIds, scope].join('|'); const { start, end } = getLocalDayRange(); setLoading(true); setError(undefined); setErrorIsRetryable(true); fetchTodaysAppointmentsForOrg(start.toISOString(), end.toISOString()) .then(async (apptBundle) => { if (token !== runToken.current) return; // superseded const appointments = (unpackBundle(apptBundle) as Appointment[]) .filter(keepAppointment); // Encounter searches REQUIRE a subject - the API forbids subject-less/org-scoped Encounter // search (Encounter/get.ts returns 400). So fetch encounters per-appointment: each request // carries the patient subject. Failure-isolated - one failed lookup just leaves that // appointment as 'scheduled' instead of blanking the whole page. const encounterLists = await Promise.all( appointments.map((appointment) => fetchEncounters({ appointment, initiator: tick.initiator }) .then((bundle) => unpackBundle(bundle) as Encounter[]) .catch(() => [] as Encounter[]), ), ); if (token !== runToken.current) return; // superseded during the per-appointment fetches // A check-in the search has not caught up to yet: read its Encounter by id, which is not stale. const found = encounterLists.flat(); const checkIns = await Promise.all( checkInsToRead(appointments, found, recentCheckInIds()).map((id) => fetchEncounterRef.current(id).catch(() => undefined)), ); if (token !== runToken.current) return; encounterLists.push(checkIns.filter((e): e is Encounter => Boolean(e))); // (B47) - overlay what this client has written but the search has not caught up to. const fetched = encounterLists.flat(); setAppointments(appointments); setEncounters(fetched); // Retire what the search has caught up to, so the overlay cannot grow across a long clinic day. setLocalEncounters((prev) => liveOverlay(prev, fetched, Date.now())); setAnsweredDay(dayKey); }) .catch((err) => { if (token !== runToken.current) return; console.error('Failed to load clinician day', err); // (B44/B46) - a permanent authorization denial must not tell the clinician to retry, // and must not keep offering the Retry button, which would fail on every press. setError(describeFetchFailure(err, 'Could not load your day')); setErrorIsRetryable(isRetryableFailure(err)); }) .finally(() => { if (token === runToken.current) setLoading(false); }); }, [practitionerId, roleIds, locationIds, scope, tick, fetchTodaysAppointmentsForOrg, fetchEncounters, unpackBundle, keepAppointment]); // (B47) - what this client wrote or was told by a broadcast, over what the search has caught up to. const dayEncounters = useMemo(() => { const overlaid = new Map(encounters.map((e) => [e.id, e])); for (const { encounter } of liveOverlay(localEncounters, encounters, Date.now())) overlaid.set(encounter.id, encounter); return [...overlaid.values()]; }, [encounters, localEncounters]); const queue = useMemo(() => buildQueue({ appointments, encounters: dayEncounters }), [appointments, dayEncounters]); // Every record this board has read or written for one of its visits is known to its viewer, so a save of that visit // (Undo check-in, Check in) names it even when the server's search later misses it. Only the visit's own patient. useEffect(() => { const visits = new Map(appointments.map((appointment) => [appointment.id, appointment])); for (const encounter of dayEncounters) { const patientId = encounter.subject?.reference?.match(/^Patient\/(.+)$/)?.[1]; for (const link of encounter.appointment ?? []) { const appointmentId = link.reference?.match(/^Appointment\/(.+)$/)?.[1]; const visit = appointmentId ? visits.get(appointmentId) : undefined; const visitPatient = visit?.participant?.find((p) => p.actor?.reference?.startsWith('Patient/'))?.actor?.reference; if (visit && patientId && visitPatient === `Patient/${patientId}`) rememberVisitRecords({ patientId, appointmentId }, [encounter.id]); } } }, [appointments, dayEncounters]); const shown = useRef({ appointments, encounters }); shown.current = { appointments, encounters }; // A broadcast carries the written resource, so it is applied as it stands; an encounter also goes on // the overlay so a search that has not caught up cannot put the visit back. const applyDayEvent = useCallback((event: DayEvent) => { if (event.kind === 'appointment') { setAppointments((prev) => applyById(prev, event.resource, !event.removed && keepAppointment(event.resource))); return; } const encounter = event.resource; const ids = new Set(shown.current.appointments.map((a) => a.id).filter((id): id is string => Boolean(id))); const kept = !event.removed && encounterBelongsToDay(encounter, ids, shown.current.encounters); setEncounters((prev) => applyById(prev, encounter, kept)); setLocalEncounters((prev) => { const rest = prev.filter((e) => e.encounter.id !== encounter.id); return kept ? [...rest, { encounter, atMs: Date.now() }] : rest; }); }, [keepAppointment]); useLiveDayEvents(applyDayEvent, poll); // The fallback read, and a read on window focus. useEffect(() => { const interval = window.setInterval(poll, REFRESH_MS); window.addEventListener('focus', refresh); return () => { window.clearInterval(interval); window.removeEventListener('focus', refresh); }; }, [refresh, poll]); const currentDay = [practitionerId, roleIds, locationIds, scope].join('|'); return { queue, loading: loading && answeredDay !== currentDay, error, errorIsRetryable, refresh, noteLocalEncounter }; };