@openmrs/esm-patient-vitals-app
Version:
Patient vitals microfrontend for the OpenMRS SPA
528 lines (463 loc) • 17.2 kB
text/typescript
import { useCallback, useEffect, useMemo } from 'react';
import {
type FHIRResource,
type FetchResponse,
fhirBaseUrl,
restBaseUrl,
openmrsFetch,
useConfig,
type OpenmrsResource,
} from '@openmrs/esm-framework';
import useSWRImmutable from 'swr/immutable';
import useSWRInfinite from 'swr/infinite';
import { type ConfigObject } from '../config-schema';
import { assessValue, calculateBodyMassIndex, getReferenceRangesForConcept, interpretBloodPressure } from './helpers';
import type {
FHIRObservationResource,
FHIRSearchBundleResponse,
MappedVitals,
PatientVitalsAndBiometrics,
VitalsResponse,
} from './types';
const pageSize = 100;
/** We use this as the first value to the SWR key to be able to invalidate all relevant cached entries */
const swrKeyNeedle = Symbol('vitalsAndBiometrics');
const encounterRepresentation =
'custom:(uuid,encounterDatetime,encounterType:(uuid,display),obs:(uuid,concept:(uuid,display),value,interpretation))';
type ConceptRange = {
display: string;
hiAbsolute: number | null;
hiCritical: number | null;
hiNormal: number | null;
lowAbsolute: number | null;
lowCritical: number | null;
lowNormal: number | null;
units: string | null;
uuid: string;
};
type VitalsAndBiometricsMode = 'vitals' | 'biometrics' | 'both';
type VitalsAndBiometricsSwrKey = {
swrKeyNeedle: typeof swrKeyNeedle;
mode: VitalsAndBiometricsMode;
patientUuid: string;
conceptUuids: string;
page: number;
prevPageData: FHIRSearchBundleResponse;
};
type VitalsFetchResponse = FetchResponse<VitalsResponse>;
export interface ConceptMetadata {
uuid: string;
display: string;
hiNormal: number | null;
hiAbsolute: number | null;
hiCritical: number | null;
lowNormal: number | null;
lowAbsolute: number | null;
lowCritical: number | null;
units: string | null;
}
interface VitalsConceptMetadataResponse {
setMembers: Array<ConceptMetadata>;
}
export type VitalsAndBiometricsFieldValuesMap = Map<string, { value: number | string; obs: OpenmrsResource }>;
interface PartialEncounter extends OpenmrsResource {
encounterType: OpenmrsResource;
encounterDatetime: string;
obs: Array<OpenmrsResource>;
}
function getInterpretationKey(header: string) {
// Reason for `Render` string is to match the column header in the table
return `${header}RenderInterpretation`;
}
export function useVitalsConceptMetadata(patientUuid: string) {
const {
concepts: {
diastolicBloodPressureUuid,
oxygenSaturationUuid,
pulseUuid,
respiratoryRateUuid,
systolicBloodPressureUuid,
temperatureUuid,
},
} = useConfig<ConfigObject>();
const apiUrl = `${restBaseUrl}/conceptreferencerange/?patient=${patientUuid}&concept=${systolicBloodPressureUuid},${diastolicBloodPressureUuid},${pulseUuid},${temperatureUuid},${oxygenSaturationUuid},${respiratoryRateUuid}&v=full`;
const { data, error, isLoading } = useSWRImmutable<{ data: any }, Error>(patientUuid ? apiUrl : null, openmrsFetch);
const conceptMetadata = data?.data?.results;
const conceptUnits = conceptMetadata?.length
? new Map<string, string>(conceptMetadata.map((concept) => [concept.uuid, concept.units]))
: new Map<string, string>([]);
const conceptRanges = useMemo(
() =>
conceptMetadata?.length
? conceptMetadata.map((concept) => ({
uuid: concept.concept,
display: concept.display,
hiNormal: concept.hiNormal ?? null,
hiAbsolute: concept.hiAbsolute ?? null,
hiCritical: concept.hiCritical ?? null,
lowNormal: concept.lowNormal ?? null,
lowAbsolute: concept.lowAbsolute ?? null,
lowCritical: concept.lowCritical ?? null,
units: concept.units ?? null,
}))
: [],
[conceptMetadata],
);
const conceptRangeMap = useMemo(
() => new Map<string, ConceptRange>(conceptRanges.map((range) => [range.uuid, range])),
[conceptRanges],
);
return {
data: conceptUnits,
error,
isLoading,
conceptMetadata,
conceptRanges,
conceptRangeMap,
};
}
export function useConceptUnits() {
const { concepts } = useConfig<ConfigObject>();
const vitalSignsConceptSetUuid = concepts.vitalSignsConceptSetUuid;
const customRepresentation = 'custom:(setMembers:(uuid,display,units))';
const apiUrl = `${restBaseUrl}/concept/${vitalSignsConceptSetUuid}?v=${customRepresentation}`;
const { data, error, isLoading } = useSWRImmutable<{ data: VitalsConceptMetadataResponse }, Error>(
apiUrl,
openmrsFetch,
);
const conceptMetadata = data?.data?.setMembers;
const conceptUnits = conceptMetadata?.length
? new Map<string, string>(conceptMetadata.map((concept) => [concept.uuid, concept.units]))
: new Map<string, string>([]);
return {
conceptUnits,
error,
isLoading,
};
}
export const withUnit = (label: string, unit: string | null | undefined) => {
return `${label} ${unit ? `(${unit})` : ''}`;
};
// We need to track a bound mutator for basically every hook, because there does not appear to be
// a way to invalidate an SWRInfinite key that works other than using the bound mutator
// Each mutator is stored in the vitalsHooksMutates map and removed (via a useEffect hook) when the
// hook is unmounted.
let vitalsHooksCounter = 0;
const vitalsHooksMutates = new Map<number, ReturnType<typeof useSWRInfinite<VitalsFetchResponse>>['mutate']>();
/**
* Hook to get concepts for vitals, biometrics or both
*/
export function useVitalsOrBiometricsConcepts(mode: VitalsAndBiometricsMode) {
const { concepts } = useConfig<ConfigObject>();
const conceptUuids = useMemo(() => {
const biometricsKeys = ['heightUuid', 'midUpperArmCircumferenceUuid', 'weightUuid'];
if (!concepts) {
return [];
}
return Object.entries(concepts)
.filter(([key, conceptUuid]) => {
if (!conceptUuid) {
console.warn(`Missing UUID for concept ${key}`);
return false;
}
if (mode === 'both') {
return true;
}
return mode === 'vitals' ? !biometricsKeys.includes(key) : biometricsKeys.includes(key);
})
.map(([_, conceptUuid]) => conceptUuid);
}, [concepts, mode]);
return conceptUuids;
}
/**
* Hook to get the vitals and / or biometrics for a patient
*
* @param patientUuid The uuid of the patient to get the vitals for
* @param mode Either 'vitals', to load only vitals, 'biometrics', to load only biometrics or 'both' to load both
* @returns An SWR-like structure that includes the cleaned-up vitals
*/
export function useVitalsAndBiometrics(patientUuid: string, mode: VitalsAndBiometricsMode = 'vitals') {
const conceptUuids = useVitalsOrBiometricsConcepts(mode);
const { concepts } = useConfig<ConfigObject>();
const { conceptRanges } = useVitalsConceptMetadata(patientUuid);
const getPage = useCallback(
(page: number, prevPageData: FHIRSearchBundleResponse): VitalsAndBiometricsSwrKey => ({
swrKeyNeedle,
mode,
patientUuid,
conceptUuids: conceptUuids.join(','),
page,
prevPageData,
}),
[mode, conceptUuids, patientUuid],
);
const { data, isLoading, isValidating, setSize, error, size, mutate } = useSWRInfinite<VitalsFetchResponse, Error>(
getPage,
handleFetch,
);
// see the comments above for why this is here
useEffect(() => {
const index = ++vitalsHooksCounter;
vitalsHooksMutates.set(index, mutate);
return () => {
vitalsHooksMutates.delete(index);
};
}, [mutate]);
const getVitalsMapKey = useCallback(
(conceptUuid: string): string => {
switch (conceptUuid) {
case concepts.systolicBloodPressureUuid:
return 'systolic';
case concepts.diastolicBloodPressureUuid:
return 'diastolic';
case concepts.pulseUuid:
return 'pulse';
case concepts.temperatureUuid:
return 'temperature';
case concepts.oxygenSaturationUuid:
return 'spo2';
case concepts.respiratoryRateUuid:
return 'respiratoryRate';
case concepts.heightUuid:
return 'height';
case concepts.weightUuid:
return 'weight';
case concepts.midUpperArmCircumferenceUuid:
return 'muac';
default:
return ''; // or throw an error for unknown conceptUuid
}
},
[
concepts.heightUuid,
concepts.midUpperArmCircumferenceUuid,
concepts.systolicBloodPressureUuid,
concepts.oxygenSaturationUuid,
concepts.diastolicBloodPressureUuid,
concepts.pulseUuid,
concepts.respiratoryRateUuid,
concepts.temperatureUuid,
concepts.weightUuid,
],
);
const formattedObs: Array<PatientVitalsAndBiometrics> = useMemo(() => {
const vitalsHashTable = data?.[0]?.data?.entry
?.map((entry) => entry.resource)
.filter(Boolean)
.map(mapVitalsAndBiometrics)
?.reduce((vitalsHashTable, vitalSign) => {
const encounterId = vitalSign.encounterId;
if (vitalsHashTable.has(encounterId) && vitalsHashTable.get(encounterId)) {
vitalsHashTable.set(encounterId, {
...vitalsHashTable.get(encounterId),
[getVitalsMapKey(vitalSign.code)]: vitalSign.value,
[getInterpretationKey(getVitalsMapKey(vitalSign.code))]: vitalSign.interpretation,
});
} else {
vitalSign.value &&
vitalsHashTable.set(encounterId, {
date:
typeof vitalSign.recordedDate === 'string'
? vitalSign.recordedDate
: vitalSign.recordedDate.toDateString(),
[getVitalsMapKey(vitalSign.code)]: vitalSign.value,
[getInterpretationKey(getVitalsMapKey(vitalSign.code))]: vitalSign.interpretation,
});
}
return vitalsHashTable;
}, new Map<string, Partial<PatientVitalsAndBiometrics>>());
return Array.from(vitalsHashTable ?? []).map(([encounterId, vitalSigns]) => {
const result = {
id: encounterId,
date: vitalSigns.date,
...vitalSigns,
};
if (mode === 'both' || mode === 'biometrics') {
result.bmi = calculateBodyMassIndex(Number(vitalSigns.weight), Number(vitalSigns.height));
}
if (mode === 'both' || mode === 'vitals') {
result.bloodPressureRenderInterpretation = interpretBloodPressure(
vitalSigns.systolic,
vitalSigns.diastolic,
concepts,
conceptRanges,
);
result.pulseRenderInterpretation = assessValue(
vitalSigns.pulse,
getReferenceRangesForConcept(concepts.pulseUuid, conceptRanges),
);
result.temperatureRenderInterpretation = assessValue(
vitalSigns.temperature,
getReferenceRangesForConcept(concepts.temperatureUuid, conceptRanges),
);
result.spo2RenderInterpretation = assessValue(
vitalSigns.spo2,
getReferenceRangesForConcept(concepts.oxygenSaturationUuid, conceptRanges),
);
result.respiratoryRateRenderInterpretation = assessValue(
vitalSigns.respiratoryRate,
getReferenceRangesForConcept(concepts.respiratoryRateUuid, conceptRanges),
);
}
return result;
});
}, [conceptRanges, concepts, data, getVitalsMapKey, mode]);
return {
data: data ? formattedObs : undefined,
isLoading,
error,
hasMore: data?.length
? !!data[data.length - 1].data?.link?.some((link: { relation?: string }) => link.relation === 'next')
: false,
isValidating,
loadingNewData: isValidating,
setPage: setSize,
currentPage: size,
totalResults: data?.[0]?.data?.total ?? undefined,
mutate,
};
}
/**
* Hook to get the vitals and biometrics for a patient associated with a specific encounter
*/
export function useEncounterVitalsAndBiometrics(encounterUuid: string) {
const { concepts } = useConfig<ConfigObject>();
const fieldNameSuffix = 'Uuid';
const url = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}?v=${encounterRepresentation}` : null;
const { data, isLoading, error, mutate } = useSWRImmutable<FetchResponse<PartialEncounter>, Error>(url, openmrsFetch);
const vitalsAndBiometrics: VitalsAndBiometricsFieldValuesMap = useMemo(() => {
if (!isLoading && data && concepts) {
return data.data.obs.reduce((vitalsAndBiometrics, obs) => {
let fieldName = Object.entries(concepts).find(([, value]) => value === obs.concept.uuid)?.[0];
if (fieldName) {
fieldName = fieldName.endsWith(fieldNameSuffix) ? fieldName.replace(fieldNameSuffix, '') : fieldName;
vitalsAndBiometrics.set(fieldName, {
value: obs.value,
obs,
});
}
return vitalsAndBiometrics;
}, new Map<string, { value: string; obs: OpenmrsResource }>());
}
return null;
}, [isLoading, data, concepts]);
const getRefinedInitialValues = useCallback(() => {
const initialValues: Record<string, string | number> = {};
if (vitalsAndBiometrics) {
vitalsAndBiometrics.forEach((value, key) => {
initialValues[key] = value.value;
});
}
return initialValues;
}, [vitalsAndBiometrics]);
return {
isLoading,
vitalsAndBiometrics,
encounter: data?.data,
error,
mutate,
getRefinedInitialValues,
};
}
/**
* Fetcher for the useVitalsAndBiometricsHook
* @internal
*/
function handleFetch({ patientUuid, conceptUuids, page, prevPageData }: VitalsAndBiometricsSwrKey) {
if (prevPageData && !prevPageData?.data?.link.some((link) => link.relation === 'next')) {
return null;
}
let url = `${fhirBaseUrl}/Observation?subject:Patient=${patientUuid}&`;
let urlSearchParams = new URLSearchParams();
urlSearchParams.append('code', conceptUuids);
urlSearchParams.append('_summary', 'data');
urlSearchParams.append('_sort', '-date');
urlSearchParams.append('_count', pageSize.toString());
if (page) {
urlSearchParams.append('_getpagesoffset', (page * pageSize).toString());
}
return openmrsFetch<VitalsResponse>(url + urlSearchParams.toString());
}
/**
* Mapper that converts a FHIR Observation resource into a MappedVitals object.
* @internal
*/
function mapVitalsAndBiometrics(resource: FHIRObservationResource): MappedVitals {
const referenceRanges = {
uuid: resource?.code?.coding?.[0]?.code,
display: resource?.code?.text,
hiNormal: null,
hiAbsolute: null,
hiCritical: null,
lowNormal: null,
lowAbsolute: null,
lowCritical: null,
units: resource.valueQuantity?.unit ?? null,
};
resource?.referenceRange?.forEach((range) => {
const rangeType = range.type?.coding?.[0]?.code;
const rangeSystem = range.type?.coding?.[0]?.system;
if (rangeSystem === 'http://terminology.hl7.org/CodeSystem/referencerange-meaning') {
if (rangeType === 'normal') {
referenceRanges.hiNormal = range.high?.value ?? null;
referenceRanges.lowNormal = range.low?.value ?? null;
} else if (rangeType === 'treatment') {
referenceRanges.hiCritical = range.high?.value ?? null;
referenceRanges.lowCritical = range.low?.value ?? null;
}
} else if (rangeSystem === 'http://fhir.openmrs.org/ext/obs/reference-range' && rangeType === 'absolute') {
referenceRanges.hiAbsolute = range.high?.value ?? null;
referenceRanges.lowAbsolute = range.low?.value ?? null;
}
});
return {
code: resource?.code?.coding?.[0]?.code,
encounterId: extractEncounterUuid(resource.encounter),
interpretation: assessValue(resource?.valueQuantity?.value, referenceRanges),
recordedDate: resource?.effectiveDateTime,
value: resource?.valueQuantity?.value,
};
}
export function createOrUpdateVitalsAndBiometrics(
patientUuid: string,
encounterTypeUuid: string,
encounterUuid: string | null,
location: string,
vitalsAndBiometricsObs: Array<OpenmrsResource>,
abortController: AbortController,
) {
const url = encounterUuid ? `${restBaseUrl}/encounter/${encounterUuid}` : `${restBaseUrl}/encounter`;
const encounter = {
patient: patientUuid,
obs: vitalsAndBiometricsObs,
};
if (!encounterUuid) {
encounter['location'] = location;
encounter['encounterType'] = encounterTypeUuid;
}
return openmrsFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
signal: abortController.signal,
body: encounter,
});
}
export function deleteEncounter(encounterUuid: string) {
return openmrsFetch(`${restBaseUrl}/encounter/${encounterUuid}`, {
method: 'DELETE',
});
}
function extractEncounterUuid(encounter: FHIRResource['resource']['encounter']): string {
if (!encounter || !encounter.reference) {
return '';
}
return encounter.reference.split('/')[1];
}
/**
* Invalidate all useVitalsAndBiometrics hooks data, to force them to reload
*/
export async function invalidateCachedVitalsAndBiometrics() {
vitalsHooksMutates.forEach((mutate) => mutate());
}