import { withRead, type ReadOptions } from '../hooks/anchorView/readOptions'; import { isHeld, overlayHeldBundle, refersToPatient, writtenSince } from '../utils/writeThroughHold'; import type { SaveTransport } from '../utils/saveOperation'; import { asRecordChanged, ifMatch, isRecordChanged, PAGE_OUT_OF_DATE_MESSAGE, RecordChangedError } from '../utils/recordChanged'; import { VISIT_CHANGED_MESSAGE, type EncounterChange } from '../utils/encounterChange'; import { useState, useCallback, createContext, useContext, ReactNode, useEffect, useRef } from 'react'; import { Appointment, Bundle, Encounter, EncounterClassHistory, EncounterHospitalization, EncounterLocation, EncounterParticipant, EncounterStatusHistory, Extension, Organization, Patient } from 'fhir/r4'; import { environment } from '../environments/environment.ts'; import { useFhirServiceContext } from './FhirService.tsx'; import { HttpError, UnreadableWriteAnswerError, useHttpService } from './HttpService.tsx'; import { ENCOUNTER_OPERATION_HEADER, isPrepareVisitAnswer, PREPARE_VISIT_OPERATION, type PrepareVisitAnswer } from '../utils/prepareVisit'; import { useAppointmentServiceContext } from './AppointmentService.tsx'; import { formatFhirHumanName } from '../utils/fhirHumanName.tsx'; import { formatFhirCodeableConcept } from '../utils/fhirCodeableConcept.tsx'; import { usePatientServiceContext } from './PatientService.tsx'; import { formatFhirPeriod } from '../utils/fhirPeriod.tsx'; import { statusLabel } from '../utils/fhirStatusVocabulary.ts'; import { applyArchive, isArchived, type ArchiveReason } from '../utils/archivedResource'; import { fetchAllPagesAsBundle } from '../utils/fetchAllPagesAsBundle'; import { searchPageSize } from '../utils/pageSizeOverride'; import { useCacheOwner } from '../utils/cacheOwner'; import { decodeVisitStartProof, isVisitStartOperationId, isVisitStartRuntimeScope, VisitStartScopeUnconfirmedError, type VisitStartProof } from '../utils/visitStartOperation'; const API_URL = environment.fhirBaseApi; /** * Who asked for a read: a person, or an unattended timer. * * The header is only sent for the background case. Saying nothing means a human, which keeps the * default truthful for the hundreds of call sites that will never think about this. */ export type ReadInitiator = 'user' | 'background'; const initiatorHeaders = (initiator: ReadInitiator) => initiator === 'background' ? { headers: { 'x-request-initiator': 'background-refresh' } } : {}; export const useEncounterService = () => { const { get, post, put, delete: del, writeWithHeaders } = useHttpService(); const { patientState } = usePatientServiceContext(); const { transformToCodeableConcept, transformFromCodeableConcept, createReferenceObject, unpackBundle, addFhirPeriodIfApplicable, addPeriodControlsIfApplicable, transformToReferenceCollection, transformFromReferenceCollection, extractReferenceId } = useFhirServiceContext(); const { getParticipantByType } = useAppointmentServiceContext(); const [encountersList, setEncountersList] = useState([]); const [shouldReload, setShouldReload] = useState(false); const [lastUpserted, setLastUpserted] = useState(null); const [unsaved, setUnsaved] = useState(false); const [encountersBundle, setEncountersBundle] = useState | null>(null); const encountersBundleRef = useRef | null>(null); const lastFetchTimeRef = useRef(0); // WHICH PATIENT THE CACHED BUNDLE WAS FETCHED FOR. The cache used to be keyed on nothing but // freshness, so a caller that switched subject without passing through a null patientState was // served the previous patient's encounters. Misattributing a visit to the wrong person is the one // thing a chart must never do, so the cache refuses to answer for a subject it was not fetched for. // Defence in depth: the root cause is fixed in PatientPage, but this is reachable from every other // surface that sets patientState directly (the day board, the appointment quick view, the patient // list). // // This was hand-rolled here before the shared helper existed, and it kept the answer PRIVATE - the // service knew whose encounters it held and no consumer could ask. So the check was right and the // hook above it still auto-loaded on "no bundle" alone, which is never true after a first patient. const cacheOwner = useCacheOwner('Encounter', () => { setLastUpserted(null); setEncountersBundle(null); setEncountersList([]); lastFetchTimeRef.current = 0; }); useEffect(() => { encountersBundleRef.current = encountersBundle; }, [encountersBundle]); useEffect(() => { if (!patientState) { setEncountersBundle(null); setEncountersList([]); lastFetchTimeRef.current = 0; cacheOwner.release(); } }, [patientState, cacheOwner.release]); /** * `initiator` SAYS WHETHER A PERSON ASKED FOR THIS READ. * * Every read is audited, and the audit used to assert that the signed-in user requested it. A staff * tab left open re-runs this search once a minute forever, so a patient's own record-activity page * showed a named practitioner reading their record 117 times overnight - 91% of a ninety-day feed, * on the one screen that exists so a patient can spot the access that should not have happened. * * The read is still audited and the row still names the actor. What changes is that the row can say * no human asked for it. Default is `user`, so a caller that says nothing is recorded the way every * read is recorded today: a missed call site is the behaviour we already have, not a new hole. */ const fetchEncounters = useCallback(async ({ patient, appointment, initiator = 'user', organizationId, transport }: { patient?: Patient | undefined, appointment?: Appointment | undefined, initiator?: ReadInitiator, organizationId?: string, transport?: SaveTransport }, options?: ReadOptions): Promise> => { const bundle = await fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); if (patient) { params.append('subject', patient.id as string); } if (appointment) { const patientReferences: string[] = []; if (appointment.participant) { appointment.participant.forEach(participant => { if (participant.actor?.reference?.startsWith('Patient/')) { patientReferences.push(participant.actor.reference); } }); } params.append('appointment', appointment.id as string); if (patientReferences && patientReferences.length !== 0) { params.append('subject', patientReferences[0]); } } if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Encounter?${params.toString()}`, withRead({ ...initiatorHeaders(initiator), organizationId, ...transport }, options)); }); return overlayHeldBundle('Encounter', bundle, (resource) => patient ? refersToPatient(resource, patient.id) : Boolean(appointment && resource.appointment?.some((ref) => ref.reference === `Appointment/${appointment.id}`))) as Bundle; }, [get]); /** * - WHICH OF MY VISITS ARE STILL OPEN, which is the one question this service could not ask. * * Every other read here is anchored on a patient or an appointment, because an Encounter search REQUIRES * a subject and the API refuses a subject-less one. That is right, and it is why a visit left open on an * earlier day could not be reached at all: the clinician's board is built from TODAY's appointments and * asks for encounters per appointment, so nothing ever asks about a visit that outlived its day. * Measured 2026-09-14: six open on one patient, the oldest since August 2, and the board reading * "IN PROGRESS 0". * * The server now accepts a subject-less search that names the CALLER as the practitioner, and enforces * that it is the caller rather than trusting the parameter. So this read can only ever return encounters * this person is personally named on, inside their own practice. * * `in-progress,onleave` because a parked visit is still an open one - the whole point is the visit * nobody closed, and parking is how a clinician leaves one without closing it. */ const fetchMyOpenEncounters = useCallback(async (practitionerId: string): Promise> => { return fetchAllPagesAsBundle((pageParam) => { const params = new URLSearchParams(); params.append('_count', searchPageSize('100')); params.append('practitioner', `Practitioner/${practitionerId}`); params.append('status', 'in-progress,onleave'); if (pageParam) params.set('page', pageParam); return get>(`${API_URL}/Encounter?${params.toString()}`, initiatorHeaders('background')); }); }, [get]); const fetchEncountersWithPolling = useCallback(async (patient: Patient, forceReload = false): Promise> => { const now = Date.now(); const cacheAge = now - lastFetchTimeRef.current; const cacheFresh = cacheAge < 5 * 60 * 1000; // The cached bundle is only valid for the patient it was fetched for. const cacheIsForThisPatient = cacheOwner.isCurrentFor(patient?.id); if (!forceReload && !shouldReload && !writtenSince('Encounter', lastFetchTimeRef.current) && encountersBundleRef.current && cacheFresh && cacheIsForThisPatient) { return encountersBundleRef.current; } const operation = cacheOwner.begin(); let retries = 10; const delay = 3000; let found = false; let bundle: Bundle | null = null; let entries: Encounter[]; do { bundle = await fetchEncounters({ patient }); entries = unpackBundle(bundle); found = lastUpserted ? entries.some(e => e.id === lastUpserted) || isHeld('Encounter', lastUpserted) : true; if (!found) { await new Promise(resolve => setTimeout(resolve, delay)); } retries--; } while (!found && retries > 0 && cacheOwner.publishes(operation)); if (!cacheOwner.publishes(operation)) return cacheOwner.answerFor(operation, bundle); setEncountersBundle(bundle); setEncountersList(entries.filter((r) => !isArchived(r))); setLastUpserted(null); setShouldReload(false); lastFetchTimeRef.current = now; // Stamped WITH the data, never before it - on a throw the owner must still name the list that is // actually in state. cacheOwner.claim(patient?.id, operation); return bundle; }, [fetchEncounters, unpackBundle, lastUpserted, shouldReload]); const fetchEncounter = useCallback(async (id: string, fresh = false, transport?: SaveTransport): Promise => { const cachedEncounter = !fresh && cacheOwner.isCurrent(cacheOwner.claimed) ? encountersList.find(encounter => encounter.id === id) : undefined; if (cachedEncounter) { return cachedEncounter; } return get(`${API_URL}/Encounter/${id}`, { ...transport }); }, [get, encountersList]); /** * The encounter as the server holds it now - never the cached list. A status write built on * `fetchEncounter` could be built on the same stale copy it exists to avoid. */ const readEncounter = useCallback(async (id: string, transport?: SaveTransport): Promise => { return get(`${API_URL}/Encounter/${id}`, { ...transport }); }, [get]); const fetchEncounterHistory = useCallback(async (id: string, options?: ReadOptions): Promise> => { return get>(`${API_URL}/Encounter/${id}/_history`, options); }, [get]); // A visit created already under way names its start operation, so a resend of the same start is answered with the // visit the server already wrote rather than creating a second one. const readVisitStartScope = useCallback(async (patientId: string, transport?: SaveTransport): Promise => { if (!/^[A-Za-z0-9.-]{1,64}$/.test(patientId)) throw new Error('Original visit patient unavailable'); transport?.admit(); const response = await get(`${API_URL}/Encounter?subject=${encodeURIComponent(patientId)}&_visit-start-scope=1`, { ...transport }); transport?.admit(); if (!response || typeof response !== 'object' || Array.isArray(response)) throw new Error('Original visit scope unavailable'); const { version, scope } = response as Record; if (version !== 1 || !isVisitStartRuntimeScope(scope) || Object.keys(response).sort().join() !== 'scope,version') throw new Error('Original visit scope unavailable'); transport?.admit(); return scope; }, [get]); const createEncounter = useCallback(async ( encounter: Encounter, transport?: SaveTransport, operationId?: string, runtimeScope?: string, viewReference?: Record, ): Promise => { let proof: VisitStartProof | undefined; if (operationId !== undefined || runtimeScope !== undefined) { if (!isVisitStartOperationId(operationId) || !isVisitStartRuntimeScope(runtimeScope) || encounter.resourceType !== 'Encounter' || encounter.status !== 'in-progress' || !/^Patient\/[A-Za-z0-9.-]{1,64}$/.test(encounter.subject?.reference ?? '') || !/^Organization\/[A-Za-z0-9.-]{1,64}$/.test(encounter.serviceProvider?.reference ?? '')) throw new Error('Original visit scope unavailable'); proof = { version: 1, operationId, scope: runtimeScope }; } let params = new URLSearchParams(); params.append('subject', extractReferenceId(encounter.subject) as string); transport?.admit(); let response: unknown; try { response = await post(`${API_URL}/Encounter?${params.toString()}`, proof ? { ...encounter, visitStart: proof } : encounter, { ...(operationId ? { headers: { [OPERATION_ID_HEADER]: operationId, ...viewReference } } : {}), ...transport }); } catch (error) { transport?.admit(); const failure = error as { status?: unknown; body?: { code?: unknown } } | null | undefined; const scopeHeld = proof && failure?.status === 409 && failure.body?.code === 'VISIT_START_SCOPE_UNCONFIRMED'; transport?.admit(); if (scopeHeld) throw new VisitStartScopeUnconfirmedError(); throw error; } transport?.admit(); if (!proof) return response as Encounter; if (!response || typeof response !== 'object' || Array.isArray(response)) throw new UnreadableWriteAnswerError(); const { visitStart, ...resource } = response as Record; const retained = decodeVisitStartProof(visitStart, proof.operationId); const result = resource as unknown as Encounter; const valid = retained && retained.scope === proof.scope && result.resourceType === 'Encounter' && /^[A-Za-z0-9.-]{1,64}$/.test(result.id ?? '') && result.subject?.reference === encounter.subject?.reference && result.serviceProvider?.reference === encounter.serviceProvider?.reference; transport?.admit(); if (!valid) throw new UnreadableWriteAnswerError(); return result; }, [post, extractReferenceId]); // PREPARE A VIDEO VISIT: the server proves the record is the open booking's and says so with a typed answer. A // typed refusal comes back as the answer; a 2xx without one, and any other failure, is thrown for the caller to // treat as unknown (the visit may have been written), never as entered. const prepareVisit = useCallback(async ( encounter: Encounter, options: { adoptUnproved?: boolean; knownRecords?: string[]; transport?: SaveTransport } = {}, ): Promise => { const params = new URLSearchParams(); params.append('subject', extractReferenceId(encounter.subject) as string); const body = { ...encounter, ...(options.adoptUnproved ? { adoptUnproved: true } : {}), ...(options.knownRecords?.length ? { knownRecords: options.knownRecords } : {}), }; try { const answer = await post(`${API_URL}/Encounter?${params.toString()}`, body, { headers: { [ENCOUNTER_OPERATION_HEADER]: PREPARE_VISIT_OPERATION }, ...options.transport }); if (isPrepareVisitAnswer(answer) && answer.result === 'entered') return answer; throw new HttpError(502, 'The visit was answered without saying what happened'); } catch (error) { if (error instanceof HttpError && isPrepareVisitAnswer(error.body) && error.body.result !== 'entered') return error.body; throw error; } }, [post, extractReferenceId]); /** * - `reopenReason` rides a HEADER, not the resource. * * Spencer, 2026-08-25: *"Reopen encounter should include a reason why reopening."* * * It is a fact about why the RECORD was changed, not about the visit, so it belongs on the * AuditEvent the server writes and nowhere else. Putting it in the Encounter would make it a * clinical field a practitioner reads back, and the server's field protection would have to be * taught to carry a value with no clinical meaning. * Only sent when there is one - an empty header is noise on every other encounter write. */ // CONDITIONAL: written only over `basedOn`, the version the editor opened on or the decision was made on. A // refusal is a RecordChangedError: 412 another write landed first, 428 no version named. // A saved Finish whose appointment the server could not close yet still answers with the visit; // `onSettlementPending` is told so, and only its caller decides whether it may still say so. const updateEncounter = useCallback(async ( encounter: Encounter, basedOn: string | undefined, options?: EncounterWriteOptions, ): Promise => { if (!basedOn) throw new RecordChangedError(PAGE_OUT_OF_DATE_MESSAGE, true); const reason = options?.reopenReason?.trim(); const url = `${API_URL}/Encounter/${encounter.id}`; const request = { headers: { ...ifMatch(basedOn), ...(reason ? { 'x-reopen-reason': reason } : {}), ...(options?.operationId ? { [OPERATION_ID_HEADER]: options.operationId } : {}), ...(options?.operationId ? options.viewReference : {}) }, ...options?.transport, }; const settlementPending = options?.onSettlementPending; if (!settlementPending) return put(url, encounter, request).catch((error: unknown) => { throw asRecordChanged(error); }); const written = await writeWithHeaders('PUT', url, encounter, request) .catch((error: unknown) => { throw asRecordChanged(error); }); if (written.header(APPOINTMENT_SETTLEMENT_HEADER) === 'pending') settlementPending(); return written.data; }, [put, writeWithHeaders]); /** * One decision about a visit, made on the visit as stored now (see EncounterChange): read, the one change * applied, written over the version read. Another write winning in between is read and decided again, once; * a decision that no longer applies is a RecordChangedError carrying the visit as it is, and nothing is written. * `transport` carries the caller's operation through both reads and the write, so a retired caller sends nothing. */ const changeEncounter = useCallback(async ( id: string, change: EncounterChange, options?: EncounterWriteOptions, ): Promise => { for (let attempt = 1; ; attempt += 1) { const current = await readEncounter(id, options?.transport); options?.transport?.admit(); const next = change(JSON.parse(JSON.stringify(current)) as Encounter); if (next === 'unchanged') return current; if (next === 'refused') throw new RecordChangedError(VISIT_CHANGED_MESSAGE, false, current); try { return await updateEncounter(next, current.meta?.versionId, options); } catch (error) { if (!isRecordChanged(error) || (error as RecordChangedError).outOfDate || attempt >= 2) throw error; } } }, [readEncounter, updateEncounter]); const deleteEncounter = useCallback(async (encounter: Encounter): Promise => { return del(`${API_URL}/Encounter/${encounter.id}`); }, [del]); const archiveEncounter = useCallback(async (encounter: Encounter, reason: ArchiveReason, note?: string, transport?: SaveTransport): Promise => { // Built on the visit it is given, so written only over that visit's version. return updateEncounter(applyArchive(encounter, reason, note), encounter.meta?.versionId, { transport }); }, [updateEncounter]); const transformToFhirEncounter = useCallback((item: any, patient?: Patient, organization?: Organization, newAppointment?: Appointment | undefined): Encounter => { const extensions: Extension[] = []; const { interpreterRequired, status, statusHistory, class: encounterClass, classHistory, type, serviceType, priority, episodeOfCare, participant, appointment, reasonCode, reasonReference, diagnosis, hospitalization, location, partOf, meta } = item; const { participant: practitionerRoleParticipant, participantId: practitionerRoleParticipantId, participantDisplay: practitionerRoleParticipantDisplay, participantType: practitionerRoleParticipantType } = getParticipantByType(newAppointment, 'PractitionerRole'); const usCoreEncounterProfile: string = 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter'; const transformedReasonReference = transformToReferenceCollection(reasonReference); if (interpreterRequired) { extensions.push({ url: 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed', valueCoding: interpreterRequired }); } const transformedParticipant: EncounterParticipant[] = participant.map((p: any) => { const transformedIndividual = transformToReferenceCollection(p.individual); let encounterParticipant: EncounterParticipant = { ...(p.type && p.type.length !== 0 && { type: transformToCodeableConcept(p.type) }), ...(transformedIndividual && transformedIndividual.length !== 0 && { individual: transformedIndividual[0] }) }; encounterParticipant = addFhirPeriodIfApplicable(encounterParticipant, p, 'period'); if (Object.keys(encounterParticipant).length !== 0) { return encounterParticipant; } return null; }).filter(Boolean); let transformedHospitalization: EncounterHospitalization | undefined; if (hospitalization) { const { origin, admitSource, reAdmission, dietPreference, specialCourtesy, specialArrangement, destination, dischargeDisposition } = hospitalization; transformedHospitalization = { ...(origin && { origin: origin }), ...(admitSource && { admitSource: transformToCodeableConcept(admitSource) }), ...(reAdmission && { reAdmission: transformToCodeableConcept(reAdmission) }), ...(dietPreference && dietPreference.length !== 0 && { dietPreference: transformToCodeableConcept(dietPreference) }), ...(specialCourtesy && specialCourtesy.length !== 0 && { specialCourtesy: transformToCodeableConcept(specialCourtesy) }), ...(specialArrangement && specialArrangement.length !== 0 && { specialArrangement: transformToCodeableConcept(specialArrangement) }), ...(destination && { destination: destination }), ...(dischargeDisposition && { dischargeDisposition: transformToCodeableConcept(dischargeDisposition) }) }; } let encounter: Encounter = { resourceType: 'Encounter', meta: { ...meta, profile: [usCoreEncounterProfile] }, status: status, class: encounterClass, ...(patient && { subject: createReferenceObject('Patient', patient.id as string, formatFhirHumanName(patient.name)) }), ...(organization && { serviceProvider: createReferenceObject('Organization', organization.id as string, organization?.name) }), ...(extensions.length !== 0 && { extension: extensions }), ...(type && type.length !== 0 && { type: transformToCodeableConcept(type) }), ...(serviceType && { serviceType: transformToCodeableConcept(serviceType) }), ...(priority && { priority: transformToCodeableConcept(priority) }), ...(reasonCode && reasonCode.length !== 0 && { reasonCode: transformToCodeableConcept(reasonCode) }), ...(transformedReasonReference && transformedReasonReference.length !== 0 && { reasonReference: transformedReasonReference }), ...(partOf && { partOf: partOf }), ...(episodeOfCare && episodeOfCare.length !== 0 && { episodeOfCare: episodeOfCare }), ...(transformedParticipant && transformedParticipant.length !== 0 && { participant: transformedParticipant }), ...(transformedHospitalization && Object.keys(transformedHospitalization).length !== 0 && { hospitalization: transformedHospitalization }), ...(newAppointment && { appointment: [createReferenceObject('Appointment', newAppointment.id)] }), ...(appointment && appointment.length !== 0 && { appointment: appointment }), ...(statusHistory && statusHistory.length !== 0 && { statusHistory: statusHistory.map((history: any) => { // @ts-ignore const statusHistoryItem: EncounterStatusHistory = { status: history.status }; return addFhirPeriodIfApplicable(statusHistoryItem, history, 'period'); }) }), ...(classHistory && classHistory.length !== 0 && { classHistory: classHistory.map((history: any) => { // @ts-ignore const classHistoryItem: EncounterClassHistory = { class: history.class, }; return addFhirPeriodIfApplicable(classHistoryItem, history, 'period'); }) }), ...(location && location.length !== 0 && { location: location.map((l: any) => { const encounterLocation: EncounterLocation = { location: l.location, ...(l.status && { status: l.status }), ...(l.physicalType && { physicalType: transformToCodeableConcept(l.physicalType) }), }; return addFhirPeriodIfApplicable(encounterLocation, l, 'period'); }) }), ...(diagnosis && diagnosis.length !== 0 && { diagnosis: diagnosis.map((d: any) => ({ condition: transformToReferenceCollection(d.condition)[0], ...(d.use && { use: transformToCodeableConcept(d.use) }), ...(d.rank && { rank: +d.rank }) })) }) }; return addFhirPeriodIfApplicable(encounter, item, 'period'); }, [createReferenceObject, transformToCodeableConcept, addFhirPeriodIfApplicable, transformToReferenceCollection]); const transformFromFhirEncounter = useCallback((encounter: Encounter): any => { const extensions: Extension[] = encounter.extension ?? []; const usCoreInterpreterRequiredExtension = extensions.find((ext: Extension) => ext.url === 'http://hl7.org/fhir/us/core/StructureDefinition/us-core-interpreter-needed')?.valueCoding; const { status, statusHistory, class: encounterClass, classHistory, type, serviceType, priority, episodeOfCare, participant, appointment, reasonCode, reasonReference, diagnosis, hospitalization, location, partOf, meta } = encounter; const formData = { meta: meta, interpreterRequired: usCoreInterpreterRequiredExtension, status: status, partOf: partOf, statusHistory: (statusHistory ?? []).map(history => { const statusHistoryItem = { status: history.status }; return addPeriodControlsIfApplicable(statusHistoryItem, history, 'period'); }), class: encounterClass, classHistory: (classHistory ?? []).map(history => { const statusHistoryItem = { class: history.class }; return addPeriodControlsIfApplicable(statusHistoryItem, history, 'period'); }), type: transformFromCodeableConcept(type), serviceType: transformFromCodeableConcept(serviceType), priority: transformFromCodeableConcept(priority), episodeOfCare: episodeOfCare ?? [], appointment: appointment ?? [], reasonCode: transformFromCodeableConcept(reasonCode), reasonReference: transformFromReferenceCollection(reasonReference ?? []), participant: (participant ?? []).map(p => { const encounterParticipant = { type: transformFromCodeableConcept(p.type ?? []), individual: transformFromReferenceCollection(p.individual ? [p.individual] : []) }; return addPeriodControlsIfApplicable(encounterParticipant, p); }), location: (location ?? []).map(l => { const encounterLocation: any = { location: l.location, status: l.status, physicalType: transformFromCodeableConcept(l.physicalType) }; return addPeriodControlsIfApplicable(encounterLocation, l); }), diagnosis: (diagnosis ?? []).map(d => ({ condition: transformFromReferenceCollection(d.condition ? [d.condition] : []), use: transformFromCodeableConcept(d.use), rank: d.rank })), hospitalization: { preAdmissionIdentifier: hospitalization?.preAdmissionIdentifier, origin: hospitalization?.origin, admitSource: transformFromCodeableConcept(hospitalization?.admitSource), reAdmission: transformFromCodeableConcept(hospitalization?.reAdmission), dietPreference: transformFromCodeableConcept(hospitalization?.dietPreference ?? []), specialCourtesy: transformFromCodeableConcept(hospitalization?.specialCourtesy ?? []), specialArrangement: transformFromCodeableConcept(hospitalization?.specialArrangement ?? []), destination: hospitalization?.destination, dischargeDisposition: transformFromCodeableConcept(hospitalization?.dischargeDisposition) } }; return addPeriodControlsIfApplicable(formData, encounter); }, [transformFromCodeableConcept, addPeriodControlsIfApplicable, transformFromReferenceCollection]); /** * / finding B21 - the patient's own visit history, written as a visit rather than as a record. * * WHAT THIS REPLACED, verbatim from the patient portal: `Encounter [finished]`, * `Encounter [in progress] (From Aug 2, 2026)`, `[finished] (Jul 18 - Jul 19)`. Rows were * indistinguishable from one another - no practitioner, no reason, no location - and the ones with no * `type` were titled with the literal word "Encounter". A patient cannot answer *"when did I come in * about my knee?"* from that. * * The shape is now **what it was - why - who - where**, with a human status only when it adds * something. Each part is omitted when absent rather than filled with a placeholder, because a row * reading "Visit - Unknown practitioner - Unknown location" is noise dressed as information. * * THE STATUS COMES FROM THE SHARED VOCABULARY, never from the code. `statusLabel` returns * `undefined` for a code we have no words for, and this omits it in that case - an unmapped status * must not reach a patient looking like a deliberate label, which is the defect arriving through the * fallback. `utils/fhirStatusVocabulary.ts` * * `finished` deliberately reads as "Completed" and is then DROPPED from the string: a visit in the * past that completed normally is the unremarkable case, and saying so on every row is how the useful * statuses (Cancelled, In progress) stop being noticed. */ const getEncounterDisplay = (encounter: Encounter): string => { if (!encounter) { return 'Unknown Encounter'; } const parts: string[] = []; const encounterType = encounter.type && encounter.type.length > 0 ? encounter.type.map(type => formatFhirCodeableConcept(type)).join(', ') : undefined; // "Visit" rather than "Encounter": the patient's word for it, and the card's standard is to speak // in the reader's terms rather than name the resource. parts.push(encounterType || 'Visit'); const reason = encounter.reasonCode && encounter.reasonCode.length > 0 ? encounter.reasonCode.map(reason => formatFhirCodeableConcept(reason)).join(', ') : undefined; if (reason) { parts.push(reason); } const practitioner = (encounter.participant ?? []) .map(participant => participant.individual?.display) .find(display => !!display); if (practitioner) { parts.push(practitioner); } const location = (encounter.location ?? []) .map(entry => entry.location?.display) .find(display => !!display); if (location) { parts.push(location); } const label = statusLabel(encounter.status); if (label && label !== 'Completed') { parts.push(label); } const dateSuffix = encounter.period ? ` (${formatFhirPeriod(encounter.period)})` : ''; return `${parts.join(' ยท ')}${dateSuffix}`; }; return { readVisitStartScope, // WHO THE CACHED LIST IS FOR, so a consumer can ask whether it belongs to the patient on // screen rather than trusting an app-wide context with no patient in its name. cachedForPatientId: cacheOwner.ownerId, encountersBundle, encountersList, setEncountersList, shouldReload, setShouldReload, lastUpserted, setLastUpserted, unsaved, setUnsaved, fetchEncounters, fetchMyOpenEncounters, fetchEncountersWithPolling, fetchEncounter, readEncounter, fetchEncounterHistory, createEncounter, prepareVisit, updateEncounter, changeEncounter, deleteEncounter, archiveEncounter, transformToFhirEncounter, transformFromFhirEncounter, getEncounterDisplay }; }; export const APPOINTMENT_SETTLEMENT_HEADER = 'X-Appointment-Settlement'; export const SETTLEMENT_PENDING_MESSAGE = 'Visit finished; appointment update pending.'; /** The visit start a write is, kept by the caller and resent unchanged on a retry. */ export const OPERATION_ID_HEADER = 'X-Operation-Id'; export interface EncounterWriteOptions { reopenReason?: string; /** Names the visit start this write is; only a write that starts the visit uses it. */ operationId?: string; /** The original view this start names, sent only with its operation. */ viewReference?: Record; transport?: SaveTransport; /** The visit was saved finished, and closing its appointment is left to the server's own settlement. */ onSettlementPending?: () => void; } const EncounterServiceContext = createContext | undefined>(undefined); export const EncounterServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const encounterService = useEncounterService(); return ( {children} ); }; export const useEncounterServiceContext = () => { const context = useContext(EncounterServiceContext); if (context === undefined) { throw new Error('useEncounterServiceContext must be used within an EncounterServiceProvider'); } return context; }; /** The service when mounted, undefined otherwise - for app-wide surfaces that also render outside it. */ export const useOptionalEncounterServiceContext = () => useContext(EncounterServiceContext);