@openmrs/esm-patient-tests-app 11.3.1-pre.9224 → 11.3.1-pre.9251

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ import type {
10
10
  TimelineDataGroupProps,
11
11
  } from './grouped-timeline-types';
12
12
  import FilterContext from '../filter/filter-context';
13
+ import { getMostRecentObservationWithRange } from './reference-range-helpers';
13
14
  import styles from './grouped-timeline.scss';
14
15
 
15
16
  export const ShadowBox: React.FC = () => <div className={styles['shadow-box']} />;
@@ -196,14 +197,20 @@ const DataRows: React.FC<DataRowsProps> = ({ patientUuid, timeColumns, rowData,
196
197
  <Grid dataColumns={timeColumns.length} padding style={{ gridColumn: 'span 2' }}>
197
198
  {rowData.map((row, index) => {
198
199
  const obs = row.entries;
199
- const { units = '', range = '', obs: values } = row;
200
+ const { obs: values } = row;
200
201
  const isString = isNaN(parseFloat(values?.[0]?.value));
202
+
203
+ // Note: Units are only at the concept/node level, not observation-level
204
+ const mostRecentObsWithRange = getMostRecentObservationWithRange(row.entries);
205
+ const displayRange = mostRecentObsWithRange?.range ?? row.range ?? '';
206
+ const displayUnits = row.units ?? '';
207
+
201
208
  return (
202
209
  <React.Fragment key={index}>
203
210
  <NewRowStartCell
204
211
  {...{
205
- units,
206
- range,
212
+ units: displayUnits,
213
+ range: displayRange,
207
214
  title: row.display,
208
215
  shadow: showShadow,
209
216
  conceptUuid: row.conceptUuid,
@@ -4,6 +4,7 @@ import useSWRInfinite from 'swr/infinite';
4
4
  import { openmrsFetch, restBaseUrl, type FetchResponse } from '@openmrs/esm-framework';
5
5
  import { usePatientChartStore, type OBSERVATION_INTERPRETATION } from '@openmrs/esm-patient-common-lib';
6
6
  import { assessValue, exist } from '../loadPatientTestData/helpers';
7
+ import { selectReferenceRange, formatReferenceRange, type ReferenceRanges } from './reference-range-helpers';
7
8
 
8
9
  export const getName = (prefix: string | undefined, name: string) => {
9
10
  return prefix ? `${prefix}-${name}` : name;
@@ -13,11 +14,28 @@ interface ObsTreeNode {
13
14
  flatName?: string;
14
15
  display: string;
15
16
  hasData: boolean;
17
+ hiAbsolute?: number;
18
+ hiCritical?: number;
16
19
  hiNormal?: number;
20
+ lowAbsolute?: number;
21
+ lowCritical?: number;
17
22
  lowNormal?: number;
23
+ units?: string;
18
24
  range?: string;
19
25
  subSets: Array<ObsTreeNode>;
20
- obs: Array<{ value: string; interpretation?: OBSERVATION_INTERPRETATION }>;
26
+ obs: Array<{
27
+ value: string;
28
+ interpretation?: OBSERVATION_INTERPRETATION;
29
+ obsDatetime?: string;
30
+ // Observation-level reference ranges (criteria-based)
31
+ // Note: Units are only at the concept/node level, not observation-level
32
+ hiAbsolute?: number;
33
+ hiCritical?: number;
34
+ hiNormal?: number;
35
+ lowAbsolute?: number;
36
+ lowCritical?: number;
37
+ lowNormal?: number;
38
+ }>;
21
39
  }
22
40
 
23
41
  const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
@@ -40,12 +58,58 @@ const augmentObstreeData = (node: ObsTreeNode, prefix: string | undefined) => {
40
58
  outData.subSets = outData.subSets.map((subNode: ObsTreeNode) => augmentObstreeData(subNode, outData.flatName));
41
59
  outData.hasData = outData.subSets.some((subNode: ObsTreeNode) => subNode.hasData);
42
60
  }
61
+ // Format node-level range for display (using lowNormal/hiNormal)
43
62
  if (exist(outData?.hiNormal, outData?.lowNormal)) {
44
- outData.range = `${outData.lowNormal} – ${outData.hiNormal}`;
63
+ outData.range = formatReferenceRange(
64
+ {
65
+ lowNormal: outData.lowNormal,
66
+ hiNormal: outData.hiNormal,
67
+ units: outData.units,
68
+ },
69
+ outData.units,
70
+ );
45
71
  }
72
+
46
73
  if (outData?.obs?.length) {
47
- const assess = assessValue(outData);
48
- outData.obs = outData.obs.map((ob) => ({ ...ob, interpretation: ob.interpretation ?? assess(ob.value) }));
74
+ outData.obs = outData.obs.map((ob) => {
75
+ // Note: Units are only at the concept/node level, not observation-level
76
+ const observationRanges: ReferenceRanges | undefined =
77
+ ob.lowNormal !== undefined || ob.hiNormal !== undefined
78
+ ? {
79
+ hiAbsolute: ob.hiAbsolute,
80
+ hiCritical: ob.hiCritical,
81
+ hiNormal: ob.hiNormal,
82
+ lowAbsolute: ob.lowAbsolute,
83
+ lowCritical: ob.lowCritical,
84
+ lowNormal: ob.lowNormal,
85
+ }
86
+ : undefined;
87
+
88
+ const nodeRanges: ReferenceRanges | undefined = {
89
+ hiAbsolute: outData.hiAbsolute,
90
+ hiCritical: outData.hiCritical,
91
+ hiNormal: outData.hiNormal,
92
+ lowAbsolute: outData.lowAbsolute,
93
+ lowCritical: outData.lowCritical,
94
+ lowNormal: outData.lowNormal,
95
+ units: outData.units,
96
+ };
97
+
98
+ const selectedRanges = selectReferenceRange(observationRanges, nodeRanges);
99
+ const assess = selectedRanges ? assessValue(selectedRanges) : assessValue(nodeRanges);
100
+ const interpretation = ob.interpretation ?? assess(ob.value);
101
+
102
+ // Always use node-level units since observation-level ranges don't have units
103
+ const displayRange = observationRanges
104
+ ? formatReferenceRange(observationRanges, outData.units)
105
+ : outData.range || '--';
106
+
107
+ return {
108
+ ...ob,
109
+ interpretation,
110
+ range: displayRange,
111
+ };
112
+ });
49
113
  outData.hasData = true;
50
114
  }
51
115
 
@@ -1,6 +1,7 @@
1
1
  import React, { useCallback, useMemo } from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { rangeAlreadyHasUnits } from '../grouped-timeline/reference-range-helpers';
4
5
  import {
5
6
  DataTable,
6
7
  DataTableSkeleton,
@@ -85,9 +86,19 @@ const IndividualResultsTable: React.FC<IndividualResultsTableProps> = ({ isLoadi
85
86
  () =>
86
87
  subRows?.entries.length &&
87
88
  subRows.entries.map((row, i) => {
88
- const { units = '', range = '' } = row;
89
+ // Use observation-level range/units if available, otherwise fallback to node-level
90
+ // MappedObservation has range and units fields, but they may come from node-level
91
+ const displayRange = row.range ?? '';
92
+ const displayUnits = row.units ?? '';
89
93
  const isString = isNaN(parseFloat(row.value));
90
94
 
95
+ // Check if range already includes units to avoid duplication
96
+ // formatReferenceRange includes units, so if range has units, don't append again
97
+ const hasUnits = rangeAlreadyHasUnits(displayRange, displayUnits);
98
+ const referenceRangeDisplay = hasUnits
99
+ ? displayRange
100
+ : `${displayRange || '--'} ${displayUnits || ''}`.trim() || '--';
101
+
91
102
  return {
92
103
  ...row,
93
104
  id: `${i}-${index}`,
@@ -106,10 +117,10 @@ const IndividualResultsTable: React.FC<IndividualResultsTableProps> = ({ isLoadi
106
117
  </span>
107
118
  ),
108
119
  value: {
109
- value: `${row.value} ${row.units ?? ''}`,
120
+ value: `${row.value} ${displayUnits}`,
110
121
  interpretation: row?.interpretation,
111
122
  },
112
- referenceRange: `${range || '--'} ${units || '--'}`,
123
+ referenceRange: referenceRangeDisplay,
113
124
  };
114
125
  }),
115
126
  [index, subRows, launchResultsDialog],
@@ -21,10 +21,41 @@ describe('IndividualResultsTable', () => {
21
21
  units: 'copies/ml',
22
22
  flatName: 'HIV viral load-HIV viral load',
23
23
  hasData: true,
24
+ range: '0 – 50', // Node-level range
25
+ lowNormal: 0,
26
+ hiNormal: 50,
24
27
  },
25
28
  ],
26
29
  } as GroupedObservation;
27
30
 
31
+ const mockSubRowsWithObservationRange = {
32
+ key: 'Alkaline phosphatase',
33
+ date: '2024-10-15',
34
+ flatName: 'Alkaline phosphatase',
35
+ entries: [
36
+ {
37
+ obsDatetime: '2024-10-15 03:20:19.0',
38
+ value: '15',
39
+ interpretation: 'CRITICALLY_LOW',
40
+ key: 'Alkaline phosphatase',
41
+ datatype: 'Numeric',
42
+ lowAbsolute: 0,
43
+ display: 'Alkaline phosphatase',
44
+ conceptUuid: '785AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
45
+ units: 'U/L',
46
+ flatName: 'Alkaline phosphatase',
47
+ hasData: true,
48
+ range: '35 – 147', // Observation-level range (different from node-level)
49
+ lowNormal: 35,
50
+ hiNormal: 147,
51
+ lowCritical: 25,
52
+ hiCritical: 200,
53
+ },
54
+ ],
55
+ range: '0 – 270', // Node-level range (fallback)
56
+ units: 'U/L',
57
+ } as GroupedObservation;
58
+
28
59
  const mockEmptySubRows = {
29
60
  key: 'HIV viral load',
30
61
  date: '2024-10-15',
@@ -45,6 +76,20 @@ describe('IndividualResultsTable', () => {
45
76
  expect(screen.getByText(/15-Oct-2024/i)).toBeInTheDocument();
46
77
  expect(screen.getByText(/test name/i)).toBeInTheDocument();
47
78
  expect(screen.getByText(/reference range/i)).toBeInTheDocument();
48
- expect(screen.getByRole('row', { name: /hiv viral load 45 copies\/ml -- copies\/ml/i })).toBeInTheDocument();
79
+ expect(screen.getByRole('row', { name: /hiv viral load 45 copies\/ml 0 – 50 copies\/ml/i })).toBeInTheDocument();
80
+ });
81
+
82
+ it('uses observation-level range when available', () => {
83
+ render(
84
+ <IndividualResultsTable
85
+ isLoading={false}
86
+ subRows={mockSubRowsWithObservationRange}
87
+ index={0}
88
+ title={'Alkaline phosphatase'}
89
+ />,
90
+ );
91
+
92
+ // Should display observation-level range (35 – 147) not node-level (0 – 270)
93
+ expect(screen.getByRole('row', { name: /alkaline phosphatase 15 u\/l 35 – 147 u\/l/i })).toBeInTheDocument();
49
94
  });
50
95
  });
@@ -1,6 +1,7 @@
1
1
  import React, { useMemo } from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { rangeAlreadyHasUnits } from '../grouped-timeline/reference-range-helpers';
4
5
  import {
5
6
  DataTable,
6
7
  TableContainer,
@@ -70,7 +71,10 @@ const LabSetPanel: React.FC<LabSetPanelProps> = ({ panel, activePanel, setActive
70
71
  hasRange
71
72
  ? panel.entries.map((test) => {
72
73
  const units = test.units ?? '';
73
- const range = test.range ? `${test.range} ${units}` : '--';
74
+ // Check if range already includes units to avoid duplication
75
+ // formatReferenceRange includes units, so if range has units, don't append again
76
+ const hasUnits = rangeAlreadyHasUnits(test.range, units);
77
+ const range = hasUnits ? test.range : test.range ? `${test.range} ${units}` : '--';
74
78
  return {
75
79
  id: test.conceptUuid,
76
80
  testName: test.display,
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
2
  import { type PatientData } from '@openmrs/esm-patient-common-lib';
3
3
  import loadPatientData from './loadPatientData';
4
4
 
@@ -9,13 +9,13 @@ type LoadingState = {
9
9
  };
10
10
 
11
11
  const usePatientResultsData = (patientUuid: string): LoadingState => {
12
- const [state, setState] = React.useState<LoadingState>({
12
+ const [state, setState] = useState<LoadingState>({
13
13
  sortedObs: {},
14
14
  loaded: false,
15
15
  error: undefined,
16
16
  });
17
17
 
18
- React.useEffect(() => {
18
+ useEffect(() => {
19
19
  let unmounted = false;
20
20
  if (patientUuid) {
21
21
  const [data, reloadedDataPromise] = loadPatientData(patientUuid);
@@ -3,6 +3,11 @@ import useSWR from 'swr';
3
3
  import { type FetchResponse, openmrsFetch, showSnackbar, restBaseUrl } from '@openmrs/esm-framework';
4
4
  import { assessValue } from '../loadPatientTestData/helpers';
5
5
  import { type TreeNode } from '../filter/filter-types';
6
+ import {
7
+ selectReferenceRange,
8
+ formatReferenceRange,
9
+ type ReferenceRanges,
10
+ } from '../grouped-timeline/reference-range-helpers';
6
11
 
7
12
  function computeTrendlineData(treeNode: TreeNode): Array<TreeNode> {
8
13
  const tests: Array<TreeNode> = [];
@@ -11,14 +16,50 @@ function computeTrendlineData(treeNode: TreeNode): Array<TreeNode> {
11
16
  }
12
17
  treeNode?.subSets.forEach((subNode) => {
13
18
  if ((subNode as TreeNode)?.obs) {
14
- const TreeNode = subNode as TreeNode;
15
- const assess = assessValue(TreeNode);
19
+ const subTreeNode = subNode as TreeNode;
20
+ // Node-level reference ranges for trendline (aggregate view)
21
+ const nodeRanges: ReferenceRanges = {
22
+ hiAbsolute: subTreeNode.hiAbsolute,
23
+ hiCritical: subTreeNode.hiCritical,
24
+ hiNormal: subTreeNode.hiNormal,
25
+ lowAbsolute: subTreeNode.lowAbsolute,
26
+ lowCritical: subTreeNode.lowCritical,
27
+ lowNormal: subTreeNode.lowNormal,
28
+ units: subTreeNode.units,
29
+ };
30
+
31
+ const range = formatReferenceRange(nodeRanges, subTreeNode.units);
32
+
33
+ const processedObs = subTreeNode.obs.map((ob) => {
34
+ // Note: Units are only at the concept/node level, not observation-level
35
+ const observationRanges: ReferenceRanges | undefined =
36
+ ob.lowNormal !== undefined || ob.hiNormal !== undefined
37
+ ? {
38
+ hiAbsolute: ob.hiAbsolute,
39
+ hiCritical: ob.hiCritical,
40
+ hiNormal: ob.hiNormal,
41
+ lowAbsolute: ob.lowAbsolute,
42
+ lowCritical: ob.lowCritical,
43
+ lowNormal: ob.lowNormal,
44
+ }
45
+ : undefined;
46
+
47
+ const selectedRanges = selectReferenceRange(observationRanges, nodeRanges);
48
+ const assess = selectedRanges ? assessValue(selectedRanges) : assessValue(nodeRanges);
49
+ const interpretation = ob.interpretation ?? assess(ob.value);
50
+
51
+ return {
52
+ ...ob,
53
+ interpretation,
54
+ lowNormal: ob.lowNormal,
55
+ hiNormal: ob.hiNormal,
56
+ };
57
+ });
58
+
16
59
  tests.push({
17
- ...TreeNode,
18
- range: TreeNode.hiNormal && TreeNode.lowNormal ? `${TreeNode.lowNormal} - ${TreeNode.hiNormal}` : '',
19
- obs: TreeNode.obs.map((ob) => {
20
- return { ...ob, interpretation: ob.interpretation ?? assess(ob.value) };
21
- }),
60
+ ...subTreeNode,
61
+ range,
62
+ obs: processedObs,
22
63
  });
23
64
  } else if (subNode?.subSets) {
24
65
  const subTreesTests = computeTrendlineData(subNode as TreeNode); // recursion
package/src/types.ts CHANGED
@@ -142,6 +142,15 @@ export type Observation = {
142
142
  obsDatetime: string;
143
143
  value: string;
144
144
  interpretation: OBSERVATION_INTERPRETATION;
145
+ // Observation-level reference ranges (criteria-based)
146
+ // Note: Units are only at the concept/node level (TestResult.units), not observation-level
147
+ hiAbsolute?: number;
148
+ hiCritical?: number;
149
+ hiNormal?: number;
150
+ lowAbsolute?: number;
151
+ lowCritical?: number;
152
+ lowNormal?: number;
153
+ range?: string; // Formatted range string for display
145
154
  };
146
155
 
147
156
  export type TestResult = {
@@ -168,6 +177,8 @@ export type MappedObservation = {
168
177
  units: string;
169
178
  lowCritical: number;
170
179
  hiNormal: number;
180
+ hiAbsolute?: number;
181
+ hiCritical?: number;
171
182
  flatName: string;
172
183
  hasData: boolean;
173
184
  range: string;
@@ -13,7 +13,7 @@
13
13
  "clearFilters": "Effacer les filtres",
14
14
  "clearSearchResults": "Effacer les résultats",
15
15
  "closeSearchBar": "Fermer la recherche",
16
- "collapseAll": "Collapse all",
16
+ "collapseAll": "Tout réduire",
17
17
  "confirmationText": "Voulez-vous modifier les résultats du test {{test}} pour le patient suivant ?",
18
18
  "data": "donnée",
19
19
  "dataLoadError": "Erreur lors du chargement des données",
@@ -29,7 +29,7 @@
29
29
  "endDate": "Date de fin",
30
30
  "error": "Erreur",
31
31
  "errorFetchingTestTypes": "Erreur lors de la récupération des résultats pour « {{searchTerm}} »",
32
- "expandAll": "Expand all",
32
+ "expandAll": "Tout étendre",
33
33
  "female": "Féminin",
34
34
  "gender": "Sexe",
35
35
  "goToDrugOrderForm": "Prescrire",