@openmrs/esm-patient-tests-app 11.3.1-patch.9064 → 11.3.1-patch.9508
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.
- package/.turbo/turbo-build.log +22 -22
- package/dist/1119.js +1 -1
- package/dist/1197.js +1 -1
- package/dist/{6231.js → 1477.js} +1 -1
- package/dist/1477.js.map +1 -0
- package/dist/1638.js +1 -1
- package/dist/1638.js.map +1 -1
- package/dist/1935.js +1 -1
- package/dist/1935.js.map +1 -1
- package/dist/2146.js +1 -1
- package/dist/2690.js +1 -1
- package/dist/3099.js +1 -1
- package/dist/34.js +1 -1
- package/dist/34.js.map +1 -1
- package/dist/3509.js +1 -1
- package/dist/3509.js.map +1 -1
- package/dist/3584.js +1 -1
- package/dist/4055.js +1 -1
- package/dist/4132.js +1 -1
- package/dist/4300.js +1 -1
- package/dist/4335.js +1 -1
- package/dist/439.js +1 -0
- package/dist/4618.js +1 -1
- package/dist/4652.js +1 -1
- package/dist/4944.js +1 -1
- package/dist/5173.js +1 -1
- package/dist/5241.js +1 -1
- package/dist/5442.js +1 -1
- package/dist/5661.js +1 -1
- package/dist/5670.js +1 -1
- package/dist/5670.js.map +1 -1
- package/dist/6022.js +1 -1
- package/dist/6113.js +1 -0
- package/dist/6113.js.map +1 -0
- package/dist/6301.js +1 -1
- package/dist/6301.js.map +1 -1
- package/dist/6336.js +1 -0
- package/dist/6336.js.map +1 -0
- package/dist/6468.js +1 -1
- package/dist/6589.js +1 -0
- package/dist/6679.js +1 -1
- package/dist/6840.js +1 -1
- package/dist/6859.js +1 -1
- package/dist/7097.js +1 -1
- package/dist/7159.js +1 -1
- package/dist/7202.js +1 -0
- package/dist/7202.js.map +1 -0
- package/dist/723.js +1 -1
- package/dist/7617.js +1 -1
- package/dist/790.js +1 -1
- package/dist/790.js.map +1 -1
- package/dist/795.js +1 -1
- package/dist/8163.js +1 -1
- package/dist/8307.js +2 -0
- package/dist/8307.js.map +1 -0
- package/dist/8349.js +1 -1
- package/dist/8371.js +1 -0
- package/dist/8555.js +2 -0
- package/dist/8555.js.map +1 -0
- package/dist/8618.js +1 -1
- package/dist/890.js +1 -1
- package/dist/9214.js +1 -1
- package/dist/9538.js +1 -1
- package/dist/9569.js +1 -1
- package/dist/986.js +1 -1
- package/dist/9879.js +1 -1
- package/dist/9895.js +1 -1
- package/dist/9900.js +1 -1
- package/dist/9913.js +1 -1
- package/dist/main.js +1 -1
- package/dist/main.js.map +1 -1
- package/dist/openmrs-esm-patient-tests-app.js +1 -1
- package/dist/openmrs-esm-patient-tests-app.js.buildmanifest.json +319 -249
- package/dist/openmrs-esm-patient-tests-app.js.map +1 -1
- package/dist/routes.json +1 -1
- package/package.json +3 -3
- package/src/edit-test-results/modal/edit-lab-results.modal.tsx +6 -2
- package/src/index.ts +1 -1
- package/src/routes.json +2 -2
- package/src/test-orders/add-test-order/add-test-order.test.tsx +13 -10
- package/src/test-orders/add-test-order/add-test-order.workspace.tsx +43 -7
- package/src/test-orders/add-test-order/test-order-form.component.tsx +41 -7
- package/src/test-orders/add-test-order/test-type-search.component.tsx +56 -8
- package/src/test-orders/lab-order-basket-panel/lab-icon.component.tsx +27 -0
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.extension.tsx +62 -15
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.scss +26 -11
- package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.test.tsx +18 -5
- package/src/test-results/filter/filter-context.test.tsx +556 -0
- package/src/test-results/filter/filter-context.tsx +1 -1
- package/src/test-results/filter/filter-reducer.test.ts +540 -0
- package/src/test-results/filter/filter-reducer.ts +1 -1
- package/src/test-results/filter/filter-set.component.tsx +75 -48
- package/src/test-results/filter/filter-set.test.tsx +694 -0
- package/src/test-results/filter/filter-types.ts +24 -1
- package/src/test-results/grouped-timeline/grid.component.tsx +4 -2
- package/src/test-results/grouped-timeline/grouped-timeline.component.tsx +20 -22
- package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +52 -2
- package/src/test-results/grouped-timeline/reference-range-helpers.test.ts +308 -0
- package/src/test-results/grouped-timeline/reference-range-helpers.ts +161 -0
- package/src/test-results/grouped-timeline/timeline-data-group.component.tsx +13 -6
- package/src/test-results/grouped-timeline/useObstreeData.test.ts +471 -0
- package/src/test-results/grouped-timeline/useObstreeData.ts +108 -13
- package/src/test-results/individual-results-table/individual-results-table.component.tsx +18 -6
- package/src/test-results/individual-results-table/individual-results-table.test.tsx +65 -3
- package/src/test-results/individual-results-table-tablet/helper.tsx +8 -2
- package/src/test-results/individual-results-table-tablet/individual-results-table-tablet.component.tsx +5 -5
- package/src/test-results/individual-results-table-tablet/lab-set-panel.component.tsx +2 -1
- package/src/test-results/individual-results-table-tablet/usePanelData.tsx +40 -26
- package/src/test-results/loadPatientTestData/helpers.test.ts +834 -0
- package/src/test-results/loadPatientTestData/helpers.ts +143 -12
- package/src/test-results/loadPatientTestData/loadPatientData.ts +66 -11
- package/src/test-results/loadPatientTestData/usePatientResultsData.ts +20 -9
- package/src/test-results/overview/common-datatable.component.tsx +1 -1
- package/src/test-results/overview/external-overview.extension.tsx +1 -2
- package/src/test-results/overview/useOverviewData.ts +22 -10
- package/src/test-results/print-modal/print-modal.extension.tsx +1 -1
- package/src/test-results/results-viewer/results-viewer.extension.tsx +12 -7
- package/src/test-results/tree-view/tree-view.component.tsx +31 -8
- package/src/test-results/tree-view/tree-view.test.tsx +119 -2
- package/src/test-results/trendline/trendline-resource.tsx +48 -5
- package/src/test-results/trendline/trendline.component.tsx +88 -52
- package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-data-illustration.tsx +2 -2
- package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-state.component.tsx +5 -6
- package/src/types.ts +20 -1
- package/translations/am.json +3 -4
- package/translations/ar.json +3 -4
- package/translations/ar_SY.json +3 -4
- package/translations/bn.json +3 -4
- package/translations/cs.json +119 -0
- package/translations/de.json +3 -4
- package/translations/en.json +3 -2
- package/translations/en_US.json +3 -4
- package/translations/es.json +3 -4
- package/translations/es_MX.json +3 -4
- package/translations/fr.json +5 -6
- package/translations/he.json +3 -4
- package/translations/hi.json +3 -4
- package/translations/hi_IN.json +3 -4
- package/translations/id.json +3 -4
- package/translations/it.json +3 -4
- package/translations/ka.json +3 -4
- package/translations/km.json +3 -4
- package/translations/ku.json +3 -4
- package/translations/ky.json +3 -4
- package/translations/lg.json +3 -4
- package/translations/ne.json +3 -4
- package/translations/pl.json +3 -4
- package/translations/pt.json +3 -4
- package/translations/pt_BR.json +3 -4
- package/translations/qu.json +3 -4
- package/translations/ro_RO.json +3 -4
- package/translations/ru_RU.json +3 -4
- package/translations/si.json +3 -4
- package/translations/sq.json +119 -0
- package/translations/sw.json +3 -4
- package/translations/sw_KE.json +3 -4
- package/translations/tr.json +3 -4
- package/translations/tr_TR.json +3 -4
- package/translations/uk.json +3 -4
- package/translations/uz.json +3 -4
- package/translations/uz@Latn.json +3 -4
- package/translations/uz_UZ.json +3 -4
- package/translations/vi.json +3 -4
- package/translations/zh.json +3 -4
- package/translations/zh_CN.json +3 -4
- package/translations/zh_TW.json +119 -0
- package/dist/1479.js +0 -1
- package/dist/1479.js.map +0 -1
- package/dist/2537.js +0 -1
- package/dist/2537.js.map +0 -1
- package/dist/4918.js +0 -1
- package/dist/4918.js.map +0 -1
- package/dist/5836.js +0 -2
- package/dist/5836.js.map +0 -1
- package/dist/6231.js.map +0 -1
- package/dist/7053.js +0 -2
- package/dist/7053.js.map +0 -1
- /package/dist/{7053.js.LICENSE.txt → 8307.js.LICENSE.txt} +0 -0
- /package/dist/{5836.js.LICENSE.txt → 8555.js.LICENSE.txt} +0 -0
- /package/src/test-results/ui-elements/{resetFiltersEmptyState/index.scss → reset-filters-empty-state/filter-empty-state.scss} +0 -0
|
@@ -14,6 +14,7 @@ export interface TreeNode {
|
|
|
14
14
|
flatName: string;
|
|
15
15
|
subSets?: Array<TreeNode>;
|
|
16
16
|
hasData?: boolean;
|
|
17
|
+
hiAbsolute?: number;
|
|
17
18
|
hiCritical?: number;
|
|
18
19
|
hiNormal?: number;
|
|
19
20
|
lowAbsolute?: number;
|
|
@@ -52,7 +53,7 @@ export type LowestNode = Pick<TreeNode, 'display' | 'flatName'>;
|
|
|
52
53
|
export interface ReducerState {
|
|
53
54
|
checkboxes: TreeCheckboxes;
|
|
54
55
|
parents: TreeParents;
|
|
55
|
-
roots: Array<
|
|
56
|
+
roots: Array<TreeNode>;
|
|
56
57
|
tests: TreeTests;
|
|
57
58
|
lowestParents: Array<TreeNode>;
|
|
58
59
|
}
|
|
@@ -75,6 +76,15 @@ export interface ObservationData {
|
|
|
75
76
|
obsDatetime: string;
|
|
76
77
|
value: string;
|
|
77
78
|
interpretation: OBSERVATION_INTERPRETATION;
|
|
79
|
+
// Reference range fields from observation-level (criteria-based)
|
|
80
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
81
|
+
hiAbsolute?: number;
|
|
82
|
+
hiCritical?: number;
|
|
83
|
+
hiNormal?: number;
|
|
84
|
+
lowAbsolute?: number;
|
|
85
|
+
lowCritical?: number;
|
|
86
|
+
lowNormal?: number;
|
|
87
|
+
range?: string; // Formatted range string for display
|
|
78
88
|
}
|
|
79
89
|
|
|
80
90
|
export interface ParsedTimeType {
|
|
@@ -124,7 +134,20 @@ export interface RowData extends TreeNode {
|
|
|
124
134
|
obsDatetime: string;
|
|
125
135
|
value: string;
|
|
126
136
|
interpretation: OBSERVATION_INTERPRETATION;
|
|
137
|
+
// Reference range fields from observation-level (criteria-based)
|
|
138
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
139
|
+
hiAbsolute?: number;
|
|
140
|
+
hiCritical?: number;
|
|
141
|
+
hiNormal?: number;
|
|
142
|
+
lowAbsolute?: number;
|
|
143
|
+
lowCritical?: number;
|
|
144
|
+
lowNormal?: number;
|
|
145
|
+
range?: string; // Formatted range string for display
|
|
127
146
|
}
|
|
128
147
|
| undefined
|
|
129
148
|
>;
|
|
130
149
|
}
|
|
150
|
+
|
|
151
|
+
export interface EmptyStateProps {
|
|
152
|
+
clearFilter(): void;
|
|
153
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import styles from './grid.scss';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
interface GridProps {
|
|
5
5
|
children?: React.ReactNode;
|
|
6
6
|
style: React.CSSProperties;
|
|
7
7
|
padding?: boolean;
|
|
8
8
|
dataColumns: number;
|
|
9
|
-
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const Grid: React.FC<GridProps> = ({ dataColumns, style = {}, padding = false, ...props }) => {
|
|
10
12
|
return (
|
|
11
13
|
<div
|
|
12
14
|
style={{
|
|
@@ -21,29 +21,27 @@ export const GroupedTimeline: React.FC<{ patientUuid: string }> = ({ patientUuid
|
|
|
21
21
|
|
|
22
22
|
if (activeTests && timelineData && loaded && tableData) {
|
|
23
23
|
return (
|
|
24
|
-
<div>
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const subRows = rowData?.filter((row: { flatName: string }) => panelTestNames.includes(row.flatName));
|
|
24
|
+
<div className={styles.timelineDataContainer}>
|
|
25
|
+
{tableData.map((panel, index) => {
|
|
26
|
+
// Filter rowData to only include tests that belong to this panel
|
|
27
|
+
const panelTestNames = panel.entries.map((entry) => entry.flatName);
|
|
28
|
+
const subRows = rowData?.filter((row: { flatName: string }) => panelTestNames.includes(row.flatName));
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</div>
|
|
30
|
+
return (
|
|
31
|
+
subRows?.length > 0 && (
|
|
32
|
+
<div key={index}>
|
|
33
|
+
<TimelineDataGroup
|
|
34
|
+
groupNumber={index + 1}
|
|
35
|
+
parent={{ display: panel.key, flatName: panel.key }}
|
|
36
|
+
patientUuid={patientUuid}
|
|
37
|
+
setXScroll={setXScroll}
|
|
38
|
+
subRows={subRows}
|
|
39
|
+
xScroll={xScroll}
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
})}
|
|
47
45
|
</div>
|
|
48
46
|
);
|
|
49
47
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
3
2
|
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { getByTextWithMarkup } from 'tools';
|
|
4
5
|
import { showModal } from '@openmrs/esm-framework';
|
|
5
6
|
import { mockGroupedResults } from '__mocks__';
|
|
6
7
|
import { type FilterContextProps } from '../filter/filter-types';
|
|
@@ -61,7 +62,8 @@ describe('GroupedTimeline', () => {
|
|
|
61
62
|
expect(screen.getByText('Nov 9')).toBeInTheDocument();
|
|
62
63
|
expect(screen.getByText('01:39 AM')).toBeInTheDocument();
|
|
63
64
|
expect(screen.getByText('Total bilirubin')).toBeInTheDocument();
|
|
64
|
-
|
|
65
|
+
// Units are now combined with range, so if there's no range, units won't be displayed separately
|
|
66
|
+
// Total bilirubin doesn't have a range in timelineData, so units are not displayed
|
|
65
67
|
expect(screen.getByText('261.9')).toBeInTheDocument();
|
|
66
68
|
expect(screen.getByText('21.5')).toBeInTheDocument();
|
|
67
69
|
expect(screen.getByText('Serum glutamic-pyruvic transaminase')).toBeInTheDocument();
|
|
@@ -70,6 +72,54 @@ describe('GroupedTimeline', () => {
|
|
|
70
72
|
expect(screen.getByText('2.9')).toBeInTheDocument();
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
it('displays most recent observation range when available', () => {
|
|
76
|
+
const contextWithObservationRanges = {
|
|
77
|
+
...mockFilterContext,
|
|
78
|
+
timelineData: {
|
|
79
|
+
...mockFilterContext.timelineData,
|
|
80
|
+
data: {
|
|
81
|
+
...mockFilterContext.timelineData.data,
|
|
82
|
+
rowData: [
|
|
83
|
+
{
|
|
84
|
+
...mockFilterContext.timelineData.data.rowData[0],
|
|
85
|
+
range: '0 – 50', // Node-level range
|
|
86
|
+
units: 'umol/L',
|
|
87
|
+
entries: [
|
|
88
|
+
{
|
|
89
|
+
obsDatetime: '2024-05-31 01:39:03.0',
|
|
90
|
+
value: '261.9',
|
|
91
|
+
interpretation: 'NORMAL',
|
|
92
|
+
lowNormal: 35,
|
|
93
|
+
hiNormal: 50,
|
|
94
|
+
range: '35 – 50', // Observation-level range (most recent)
|
|
95
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
obsDatetime: '2023-11-09 23:15:03.0',
|
|
99
|
+
value: '21.5',
|
|
100
|
+
interpretation: 'NORMAL',
|
|
101
|
+
lowNormal: 20,
|
|
102
|
+
hiNormal: 45,
|
|
103
|
+
range: '20 – 45', // Older observation-level range
|
|
104
|
+
// Note: Units are only at the concept/node level, not observation-level
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
renderGroupedTimeline(contextWithObservationRanges as FilterContextProps);
|
|
114
|
+
|
|
115
|
+
// Should display most recent observation's range (35 – 50) not node-level (0 – 50)
|
|
116
|
+
// Range and units are displayed separately in the same element
|
|
117
|
+
const rangeElement = getByTextWithMarkup(/35 – 50/);
|
|
118
|
+
expect(rangeElement).toBeInTheDocument();
|
|
119
|
+
// Verify that the same element also contains the units
|
|
120
|
+
expect(rangeElement).toHaveTextContent('35 – 50 umol/L');
|
|
121
|
+
});
|
|
122
|
+
|
|
73
123
|
it('correctly filters rows based on checkbox selection when someChecked is true', () => {
|
|
74
124
|
renderGroupedTimeline({
|
|
75
125
|
...mockFilterContext,
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatRangeWithUnits,
|
|
3
|
+
formatReferenceRange,
|
|
4
|
+
getMostRecentObservationWithRange,
|
|
5
|
+
rangeAlreadyHasUnits,
|
|
6
|
+
selectReferenceRange,
|
|
7
|
+
type ReferenceRanges,
|
|
8
|
+
} from './reference-range-helpers';
|
|
9
|
+
|
|
10
|
+
describe('Reference Range Helpers', () => {
|
|
11
|
+
describe('selectReferenceRange', () => {
|
|
12
|
+
it('returns null when both ranges are null', () => {
|
|
13
|
+
expect(selectReferenceRange(undefined, undefined)).toBeNull();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('returns node-level range when observation range is not available', () => {
|
|
17
|
+
const nodeRanges: ReferenceRanges = {
|
|
18
|
+
lowNormal: 0,
|
|
19
|
+
hiNormal: 50,
|
|
20
|
+
units: 'mg/dL',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
expect(selectReferenceRange(undefined, nodeRanges)).toEqual(nodeRanges);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns observation-level range when node range is not available', () => {
|
|
27
|
+
const observationRanges: ReferenceRanges = {
|
|
28
|
+
lowNormal: 35,
|
|
29
|
+
hiNormal: 147,
|
|
30
|
+
units: 'U/L',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
expect(selectReferenceRange(observationRanges, undefined)).toEqual(observationRanges);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('merges ranges with observation taking precedence', () => {
|
|
37
|
+
const observationRanges: ReferenceRanges = {
|
|
38
|
+
lowNormal: 35,
|
|
39
|
+
hiNormal: 147,
|
|
40
|
+
lowCritical: 25,
|
|
41
|
+
units: 'U/L',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const nodeRanges: ReferenceRanges = {
|
|
45
|
+
lowNormal: 0,
|
|
46
|
+
hiNormal: 270,
|
|
47
|
+
hiCritical: 541,
|
|
48
|
+
units: 'U/L',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const result = selectReferenceRange(observationRanges, nodeRanges);
|
|
52
|
+
|
|
53
|
+
expect(result).toEqual({
|
|
54
|
+
lowNormal: 35, // From observation
|
|
55
|
+
hiNormal: 147, // From observation
|
|
56
|
+
lowCritical: 25, // From observation
|
|
57
|
+
hiCritical: 541, // From node (observation doesn't have it)
|
|
58
|
+
units: 'U/L',
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles partial observation ranges', () => {
|
|
63
|
+
const observationRanges: ReferenceRanges = {
|
|
64
|
+
hiNormal: 147,
|
|
65
|
+
// Missing lowNormal
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const nodeRanges: ReferenceRanges = {
|
|
69
|
+
lowNormal: 0,
|
|
70
|
+
hiNormal: 270,
|
|
71
|
+
units: 'U/L',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const result = selectReferenceRange(observationRanges, nodeRanges);
|
|
75
|
+
|
|
76
|
+
expect(result).toEqual({
|
|
77
|
+
lowNormal: 0, // From node (observation doesn't have it)
|
|
78
|
+
hiNormal: 147, // From observation
|
|
79
|
+
units: 'U/L',
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('formatReferenceRange', () => {
|
|
85
|
+
it('returns "--" when ranges is null', () => {
|
|
86
|
+
expect(formatReferenceRange(null)).toBe('--');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('formats range with both lowNormal and hiNormal', () => {
|
|
90
|
+
const ranges: ReferenceRanges = {
|
|
91
|
+
lowNormal: 0,
|
|
92
|
+
hiNormal: 50,
|
|
93
|
+
units: 'mg/dL',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
expect(formatReferenceRange(ranges)).toBe('0 – 50 mg/dL');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('formats range without units', () => {
|
|
100
|
+
const ranges: ReferenceRanges = {
|
|
101
|
+
lowNormal: 0,
|
|
102
|
+
hiNormal: 50,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
expect(formatReferenceRange(ranges)).toBe('0 – 50');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('returns "--" when lowNormal or hiNormal is missing', () => {
|
|
109
|
+
const ranges1: ReferenceRanges = {
|
|
110
|
+
hiNormal: 50,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const ranges2: ReferenceRanges = {
|
|
114
|
+
lowNormal: 0,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(formatReferenceRange(ranges1)).toBe('--');
|
|
118
|
+
expect(formatReferenceRange(ranges2)).toBe('--');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('uses provided units parameter when ranges.units is not available', () => {
|
|
122
|
+
const ranges: ReferenceRanges = {
|
|
123
|
+
lowNormal: 0,
|
|
124
|
+
hiNormal: 50,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(formatReferenceRange(ranges, 'mg/dL')).toBe('0 – 50 mg/dL');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('getMostRecentObservationWithRange', () => {
|
|
132
|
+
it('returns null when observations array is empty', () => {
|
|
133
|
+
expect(getMostRecentObservationWithRange([])).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns null when no observations have range data', () => {
|
|
137
|
+
const observations = [
|
|
138
|
+
{ obsDatetime: '2024-01-01', value: '10' },
|
|
139
|
+
{ obsDatetime: '2024-01-02', value: '20' },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
expect(getMostRecentObservationWithRange(observations)).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('returns the most recent observation with range data', () => {
|
|
146
|
+
const observations = [
|
|
147
|
+
{
|
|
148
|
+
obsDatetime: '2024-01-01',
|
|
149
|
+
value: '10',
|
|
150
|
+
lowNormal: 0,
|
|
151
|
+
hiNormal: 50,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
obsDatetime: '2024-01-03',
|
|
155
|
+
value: '30',
|
|
156
|
+
lowNormal: 35,
|
|
157
|
+
hiNormal: 147,
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
obsDatetime: '2024-01-02',
|
|
161
|
+
value: '20',
|
|
162
|
+
// No range data
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const result = getMostRecentObservationWithRange(observations);
|
|
167
|
+
|
|
168
|
+
expect(result).toEqual({
|
|
169
|
+
obsDatetime: '2024-01-03',
|
|
170
|
+
value: '30',
|
|
171
|
+
lowNormal: 35,
|
|
172
|
+
hiNormal: 147,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('handles observations with only lowNormal', () => {
|
|
177
|
+
const observations = [
|
|
178
|
+
{
|
|
179
|
+
obsDatetime: '2024-01-01',
|
|
180
|
+
value: '10',
|
|
181
|
+
lowNormal: 0,
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const result = getMostRecentObservationWithRange(observations);
|
|
186
|
+
|
|
187
|
+
expect(result).toEqual({
|
|
188
|
+
obsDatetime: '2024-01-01',
|
|
189
|
+
value: '10',
|
|
190
|
+
lowNormal: 0,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('handles observations with only hiNormal', () => {
|
|
195
|
+
const observations = [
|
|
196
|
+
{
|
|
197
|
+
obsDatetime: '2024-01-01',
|
|
198
|
+
value: '10',
|
|
199
|
+
hiNormal: 50,
|
|
200
|
+
},
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
const result = getMostRecentObservationWithRange(observations);
|
|
204
|
+
|
|
205
|
+
expect(result).toEqual({
|
|
206
|
+
obsDatetime: '2024-01-01',
|
|
207
|
+
value: '10',
|
|
208
|
+
hiNormal: 50,
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('filters out undefined entries', () => {
|
|
213
|
+
const observations = [
|
|
214
|
+
undefined,
|
|
215
|
+
{
|
|
216
|
+
obsDatetime: '2024-01-01',
|
|
217
|
+
value: '10',
|
|
218
|
+
lowNormal: 0,
|
|
219
|
+
hiNormal: 50,
|
|
220
|
+
},
|
|
221
|
+
undefined,
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const result = getMostRecentObservationWithRange(observations);
|
|
225
|
+
|
|
226
|
+
expect(result).toEqual({
|
|
227
|
+
obsDatetime: '2024-01-01',
|
|
228
|
+
value: '10',
|
|
229
|
+
lowNormal: 0,
|
|
230
|
+
hiNormal: 50,
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('rangeAlreadyHasUnits', () => {
|
|
236
|
+
it('returns false when range is undefined', () => {
|
|
237
|
+
expect(rangeAlreadyHasUnits(undefined, 'U/L')).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('returns false when units is undefined', () => {
|
|
241
|
+
expect(rangeAlreadyHasUnits('0 – 50', undefined)).toBe(false);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns false when both are undefined', () => {
|
|
245
|
+
expect(rangeAlreadyHasUnits(undefined, undefined)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('returns true when range ends with units', () => {
|
|
249
|
+
expect(rangeAlreadyHasUnits('0 – 50 U/L', 'U/L')).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('returns true when range ends with units with space', () => {
|
|
253
|
+
expect(rangeAlreadyHasUnits('0 – 50 U/L', 'U/L')).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('returns false when range does not end with units', () => {
|
|
257
|
+
expect(rangeAlreadyHasUnits('0 – 50', 'U/L')).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns false when units appear in the middle of range', () => {
|
|
261
|
+
expect(rangeAlreadyHasUnits('5 mg/dL value', 'mg/dL')).toBe(false);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('handles trimmed strings correctly', () => {
|
|
265
|
+
expect(rangeAlreadyHasUnits(' 0 – 50 U/L ', ' U/L ')).toBe(true);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('returns false for empty strings', () => {
|
|
269
|
+
expect(rangeAlreadyHasUnits('', 'U/L')).toBe(false);
|
|
270
|
+
expect(rangeAlreadyHasUnits('0 – 50', '')).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('formatRangeWithUnits', () => {
|
|
275
|
+
it('returns "--" when range is undefined', () => {
|
|
276
|
+
expect(formatRangeWithUnits(undefined, 'U/L')).toBe('--');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('returns "--" when range is empty string', () => {
|
|
280
|
+
expect(formatRangeWithUnits('', 'U/L')).toBe('--');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('returns range as-is when range already includes units', () => {
|
|
284
|
+
expect(formatRangeWithUnits('0 – 50 U/L', 'U/L')).toBe('0 – 50 U/L');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('appends units when range does not include units', () => {
|
|
288
|
+
expect(formatRangeWithUnits('0 – 50', 'U/L')).toBe('0 – 50 U/L');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('returns range without units when units is undefined', () => {
|
|
292
|
+
expect(formatRangeWithUnits('0 – 50', undefined)).toBe('0 – 50');
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('returns range without units when units is empty', () => {
|
|
296
|
+
expect(formatRangeWithUnits('0 – 50', '')).toBe('0 – 50');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('handles trimmed strings correctly', () => {
|
|
300
|
+
expect(formatRangeWithUnits(' 0 – 50 ', ' U/L ')).toBe('0 – 50 U/L');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('handles range with units and different units parameter', () => {
|
|
304
|
+
// If range already has units, don't append different units
|
|
305
|
+
expect(formatRangeWithUnits('0 – 50 mg/dL', 'U/L')).toBe('0 – 50 mg/dL');
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { exist } from '../loadPatientTestData/helpers';
|
|
2
|
+
|
|
3
|
+
export interface ReferenceRanges {
|
|
4
|
+
hiAbsolute?: number;
|
|
5
|
+
hiCritical?: number;
|
|
6
|
+
hiNormal?: number;
|
|
7
|
+
lowAbsolute?: number;
|
|
8
|
+
lowCritical?: number;
|
|
9
|
+
lowNormal?: number;
|
|
10
|
+
units?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Merges observation-level and node-level reference ranges.
|
|
15
|
+
* Observation-level ranges take precedence when available.
|
|
16
|
+
*/
|
|
17
|
+
export function selectReferenceRange(
|
|
18
|
+
observationRanges?: ReferenceRanges,
|
|
19
|
+
nodeRanges?: ReferenceRanges,
|
|
20
|
+
): ReferenceRanges | null {
|
|
21
|
+
if (!observationRanges && !nodeRanges) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!observationRanges) {
|
|
26
|
+
return nodeRanges || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!nodeRanges) {
|
|
30
|
+
return observationRanges;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Merge: observation takes precedence for available fields.
|
|
34
|
+
// Note: Units are only at the concept/node level, so units will always come from nodeRanges.
|
|
35
|
+
return {
|
|
36
|
+
hiAbsolute: observationRanges.hiAbsolute ?? nodeRanges.hiAbsolute,
|
|
37
|
+
hiCritical: observationRanges.hiCritical ?? nodeRanges.hiCritical,
|
|
38
|
+
hiNormal: observationRanges.hiNormal ?? nodeRanges.hiNormal,
|
|
39
|
+
lowAbsolute: observationRanges.lowAbsolute ?? nodeRanges.lowAbsolute,
|
|
40
|
+
lowCritical: observationRanges.lowCritical ?? nodeRanges.lowCritical,
|
|
41
|
+
lowNormal: observationRanges.lowNormal ?? nodeRanges.lowNormal,
|
|
42
|
+
units: observationRanges.units ?? nodeRanges.units,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Formats reference range string using lowNormal and hiNormal.
|
|
48
|
+
* Note: Display format using lowAbsolute/hiAbsolute with >/< is handled in a separate ticket.
|
|
49
|
+
*/
|
|
50
|
+
export function formatReferenceRange(ranges: ReferenceRanges | null, units?: string): string {
|
|
51
|
+
if (!ranges) {
|
|
52
|
+
return '--';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const { lowNormal, hiNormal } = ranges;
|
|
56
|
+
const displayUnits = ranges.units || units || '';
|
|
57
|
+
|
|
58
|
+
if (exist(lowNormal, hiNormal)) {
|
|
59
|
+
return `${lowNormal} – ${hiNormal}${displayUnits ? ` ${displayUnits}` : ''}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return '--';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Checks if a formatted range string already includes the units.
|
|
67
|
+
* This prevents duplicate units when appending units to a range that already has them.
|
|
68
|
+
* @param range The formatted range string (e.g., "0 – 50 U/L")
|
|
69
|
+
* @param units The units string (e.g., "U/L")
|
|
70
|
+
* @returns true if the range already ends with the units, false otherwise
|
|
71
|
+
*/
|
|
72
|
+
export function rangeAlreadyHasUnits(range: string | undefined, units: string | undefined): boolean {
|
|
73
|
+
if (!range || !units) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if range ends with units (with optional space before)
|
|
78
|
+
// This is more precise than includes() to avoid false positives
|
|
79
|
+
const trimmedRange = range.trim();
|
|
80
|
+
const trimmedUnits = units.trim();
|
|
81
|
+
return trimmedRange.endsWith(trimmedUnits) || trimmedRange.endsWith(` ${trimmedUnits}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Checks if a range string already contains any units (common unit patterns).
|
|
86
|
+
* This helps detect if a range already has units even if they differ from the parameter.
|
|
87
|
+
* @param range The formatted range string (e.g., "0 – 50 mg/dL")
|
|
88
|
+
* @returns true if the range appears to already contain units, false otherwise
|
|
89
|
+
*/
|
|
90
|
+
function rangeHasAnyUnits(range: string): boolean {
|
|
91
|
+
if (!range) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const trimmedRange = range.trim();
|
|
96
|
+
// Common unit patterns: ends with common unit abbreviations or contains unit-like patterns
|
|
97
|
+
// This is a heuristic to detect if units are already present
|
|
98
|
+
const unitPattern = /\s+[a-zA-Z\/%°]+$/;
|
|
99
|
+
return unitPattern.test(trimmedRange);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Formats a reference range with units for display, avoiding duplicate units.
|
|
104
|
+
* @param range The formatted range string (may or may not include units)
|
|
105
|
+
* @param units The units string to append if not already present
|
|
106
|
+
* @returns Formatted string with range and units (e.g., "0 – 50 U/L")
|
|
107
|
+
*/
|
|
108
|
+
export function formatRangeWithUnits(range: string | undefined, units: string | undefined): string {
|
|
109
|
+
const trimmedRange = range?.trim() || '';
|
|
110
|
+
const trimmedUnits = units?.trim() || '';
|
|
111
|
+
|
|
112
|
+
// If range is empty, return '--' (even if units exist, we need a range to display)
|
|
113
|
+
if (!trimmedRange) {
|
|
114
|
+
return '--';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if range already includes the specific units parameter
|
|
118
|
+
const hasSpecificUnits = rangeAlreadyHasUnits(trimmedRange, trimmedUnits);
|
|
119
|
+
if (hasSpecificUnits) {
|
|
120
|
+
return trimmedRange;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check if range already has any units (even if different from parameter)
|
|
124
|
+
// This prevents appending units when range already has different units (e.g., "0 – 50 mg/dL" with "U/L" parameter)
|
|
125
|
+
const hasAnyUnits = rangeHasAnyUnits(trimmedRange);
|
|
126
|
+
if (hasAnyUnits) {
|
|
127
|
+
return trimmedRange;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Append units if not already present
|
|
131
|
+
return trimmedUnits ? `${trimmedRange} ${trimmedUnits}` : trimmedRange;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Finds the most recent observation that has reference range data.
|
|
136
|
+
*/
|
|
137
|
+
export function getMostRecentObservationWithRange<
|
|
138
|
+
T extends { obsDatetime: string; lowNormal?: number; hiNormal?: number },
|
|
139
|
+
>(observations: Array<T | undefined>): T | null {
|
|
140
|
+
if (!observations || observations.length === 0) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Filter out undefined and find observations with range data
|
|
145
|
+
const validObservations = observations.filter(
|
|
146
|
+
(obs): obs is T => obs !== undefined && (obs.lowNormal !== undefined || obs.hiNormal !== undefined),
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
if (validObservations.length === 0) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Sort by obsDatetime descending (most recent first)
|
|
154
|
+
const sorted = [...validObservations].sort((a, b) => {
|
|
155
|
+
const dateA = new Date(a.obsDatetime).getTime();
|
|
156
|
+
const dateB = new Date(b.obsDatetime).getTime();
|
|
157
|
+
return dateB - dateA;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return sorted[0];
|
|
161
|
+
}
|