import { useMemo } from "react"; import type { DiagnosticReport, Encounter, EpisodeOfCare, MedicationRequest, Observation } from "fhir/r4"; import { useFhirDiagnosticReports } from "./useFhirDiagnosticReports"; import { useFhirEpisodesOfCare } from "./useFhirEpisodesOfCare"; import { useFhirEncounters } from "./useFhirEncounters"; import { useFhirMedicationRequests } from "./useFhirMedicationRequests"; import { useFhirObservations } from "./useFhirObservations"; import { returnedResultWithheld } from "../utils/returnedPublication"; import { oneReportPerResult } from "../utils/resultLineage"; import { careEvent, careHappened, countReportResults, groupEventsByMonth, practiceIsWorthShowing, prescriptionEvent, prescriptionWritten, resultCameBack, resultsEvent, visitEvent, visitHappened, type RecordEvent, type RecordMonth, } from "../utils/patientRecordStream"; /** * THE PATIENT'S RECORD AS ONE STREAM -, step 2. * * ASSEMBLY ONLY. Every rule about ordering, grouping and what a row says lives in * `utils/patientRecordStream.ts`, where it is testable without a network. This joins that to the data. * * WHAT IS IN IT TODAY, AND THE HONEST REASON FOR THE ONE OMISSION: * - **Visits**, **results** and **prescriptions** - each carries its own date and its own practice. * - **Imaging** is not separated out. `ImagingStudy` is not read anywhere in the product, and an * imaging report arriving as a `DiagnosticReport` already appears here as a result - which is true, * where a separate empty category would imply we hold something we do not. * * A RESULT SET IS COUNTED, NOT LISTED. "4 results" is the row and the four are what opening it is * for; a row per observation would bury a visit under the bloods it produced. And the count is taken * from the observations we actually HOLD, never from the report's own `result` array: a reference we * cannot read is not a result the patient can open, and counting it promises a row that will not be * there. * * A LOADING STREAM IS NOT AN EMPTY ONE. `loading` is true while ANY source is still answering, so the * screen can say "we have not asked yet" rather than "there is nothing" - which on a health record is * the difference between a slow page and a frightening one. */ export interface PatientRecordStream { months: RecordMonth[]; /** Every event, ungrouped - for counts and for anything that filters before it groups. */ events: RecordEvent[]; /** True whenever any event names a practice. See `practiceIsWorthShowing`. */ showPractice: boolean; loading: boolean; /** Nothing could be read, so the stream cannot say what the record holds. */ error?: string; /** Some parts could not be read: what is shown is real but may be incomplete. */ partialError?: string; /** Every read was answered with a 403. Only a real refusal sets this. */ refused: boolean; /** Some reads were answered with a 403, but not all of them. */ partlyRefused: boolean; /** The parts whose reads were answered with a 403, in reading order. */ refusedParts: string[]; /** Measurements held for this patient from a read that answered. */ measurementCount: number; /** Some reads failed for a reason other than a 403, so trying again may help. */ partlyFailed: boolean; /** Some reads answered as of a cutoff and not in full: what is shown is real, what they left out is unknown. */ incomplete: boolean; /** The parts that were not read in full, in reading order. */ incompleteParts: string[]; } /** What a read may say about THIS patient: its list only when the service holds theirs, its failure only when it was theirs. */ export interface OwnedRead { loading: boolean; error?: string | null; refused?: boolean; attempted?: boolean; listIsThisPatients: boolean; failureIsThisPatients?: boolean; /** The answer was served as of a cutoff and not in full: what it left out is unknown. */ incomplete?: boolean; } // One empty list for every unanswered read, so memos over it hold still. const NONE: never[] = []; export const ownedRead = (read: OwnedRead, list: T[] | undefined | null) => { const failed = Boolean(read.error) && read.failureIsThisPatients === true; return { list: (read.listIsThisPatients ? list ?? NONE : NONE) as T[], // Another patient's list or failure is not an answer for this one, so the read is still pending. loading: read.loading || read.attempted === false || !(read.listIsThisPatients || failed), error: failed ? read.error ?? undefined : undefined, refused: failed && read.refused === true, incomplete: Boolean(read.listIsThisPatients && read.incomplete), }; }; /** * The record's events from its source lists. Pure, so a surface that reads one patient EXPLICITLY (the * finished visit's reference, P06) builds the same events the ambient stream below does - two builders * would let "everything about this one" mean two different sets. */ export const buildRecordEvents = ({ encounters, reports, observations, episodes, prescriptions }: { encounters: Encounter[]; reports: DiagnosticReport[]; observations: Observation[]; episodes: EpisodeOfCare[]; prescriptions: MedicationRequest[]; }): RecordEvent[] => [ ...encounters.filter(visitHappened).map(visitEvent), ...oneReportPerResult(reports).filter(resultCameBack).map((report) => resultsEvent( report, countReportResults(report, observations), returnedResultWithheld(report, observations), )), ...episodes.filter(careHappened).map(careEvent), ...prescriptions.filter(prescriptionWritten).map(prescriptionEvent), ]; export const usePatientRecordStream = (): PatientRecordStream => { const encounters = useFhirEncounters(); const medications = useFhirMedicationRequests(); const reports = useFhirDiagnosticReports(); const episodes = useFhirEpisodesOfCare(); const observations = useFhirObservations(); const visits = ownedRead(encounters, encounters.encountersList); const prescriptions = ownedRead(medications, medications.medicationRequestsList); const results = ownedRead(reports, reports.diagnosticReportsList); const courses = ownedRead(episodes, episodes.episodesOfCareList); const measurements = ownedRead(observations, observations.observationsList); const encountersList = visits.list; const medicationRequestsList = prescriptions.list; const diagnosticReportsList = results.list; const episodesOfCareList = courses.list; const observationsList = measurements.list; const events = useMemo( () => buildRecordEvents({ encounters: encountersList, reports: diagnosticReportsList, observations: observationsList, episodes: episodesOfCareList, prescriptions: medicationRequestsList, }), [encountersList, diagnosticReportsList, observationsList, episodesOfCareList, medicationRequestsList], ); // Every source answers for itself. A read that has not run yet is still loading, not empty; a read // that failed is named, and the stream only claims "nothing" when every read succeeded. const sources = [ { part: 'visits', ...visits }, { part: 'prescriptions', ...prescriptions }, { part: 'test results', ...results }, { part: 'courses of care', ...courses }, { part: 'measurements', ...measurements }, ]; const failed = sources.filter((source) => Boolean(source.error)).map((source) => source.part); const unreadInFull = sources.filter((source) => source.incomplete && !source.error).map((source) => source.part); const refusedParts = sources.filter((source) => source.refused).map((source) => source.part); // A measurement that was read is part of the record the practice can see, so only five 403s refuse it. const refused = sources.every((source) => !source.loading && source.refused === true); return { events, months: useMemo(() => groupEventsByMonth(events), [events]), showPractice: useMemo(() => practiceIsWorthShowing(events), [events]), loading: sources.some((source) => source.loading), refused, partlyRefused: !refused && refusedParts.length > 0, refusedParts, measurementCount: measurements.error ? 0 : observationsList.length, partlyFailed: sources.some((source) => Boolean(source.error) && !source.refused), error: failed.length === sources.length ? 'We could not load your record just now.' : undefined, partialError: failed.length > 0 && failed.length < sources.length ? `Some of your record could not be loaded (${failed.join(', ')}), so this list may be incomplete.` : unreadInFull.length > 0 ? `Some of this record could not be read in full (${unreadInFull.join(', ')}), so this list may be incomplete.` : undefined, incomplete: unreadInFull.length > 0, incompleteParts: unreadInFull, }; };