@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.
Files changed (180) hide show
  1. package/.turbo/turbo-build.log +22 -22
  2. package/dist/1119.js +1 -1
  3. package/dist/1197.js +1 -1
  4. package/dist/{6231.js → 1477.js} +1 -1
  5. package/dist/1477.js.map +1 -0
  6. package/dist/1638.js +1 -1
  7. package/dist/1638.js.map +1 -1
  8. package/dist/1935.js +1 -1
  9. package/dist/1935.js.map +1 -1
  10. package/dist/2146.js +1 -1
  11. package/dist/2690.js +1 -1
  12. package/dist/3099.js +1 -1
  13. package/dist/34.js +1 -1
  14. package/dist/34.js.map +1 -1
  15. package/dist/3509.js +1 -1
  16. package/dist/3509.js.map +1 -1
  17. package/dist/3584.js +1 -1
  18. package/dist/4055.js +1 -1
  19. package/dist/4132.js +1 -1
  20. package/dist/4300.js +1 -1
  21. package/dist/4335.js +1 -1
  22. package/dist/439.js +1 -0
  23. package/dist/4618.js +1 -1
  24. package/dist/4652.js +1 -1
  25. package/dist/4944.js +1 -1
  26. package/dist/5173.js +1 -1
  27. package/dist/5241.js +1 -1
  28. package/dist/5442.js +1 -1
  29. package/dist/5661.js +1 -1
  30. package/dist/5670.js +1 -1
  31. package/dist/5670.js.map +1 -1
  32. package/dist/6022.js +1 -1
  33. package/dist/6113.js +1 -0
  34. package/dist/6113.js.map +1 -0
  35. package/dist/6301.js +1 -1
  36. package/dist/6301.js.map +1 -1
  37. package/dist/6336.js +1 -0
  38. package/dist/6336.js.map +1 -0
  39. package/dist/6468.js +1 -1
  40. package/dist/6589.js +1 -0
  41. package/dist/6679.js +1 -1
  42. package/dist/6840.js +1 -1
  43. package/dist/6859.js +1 -1
  44. package/dist/7097.js +1 -1
  45. package/dist/7159.js +1 -1
  46. package/dist/7202.js +1 -0
  47. package/dist/7202.js.map +1 -0
  48. package/dist/723.js +1 -1
  49. package/dist/7617.js +1 -1
  50. package/dist/790.js +1 -1
  51. package/dist/790.js.map +1 -1
  52. package/dist/795.js +1 -1
  53. package/dist/8163.js +1 -1
  54. package/dist/8307.js +2 -0
  55. package/dist/8307.js.map +1 -0
  56. package/dist/8349.js +1 -1
  57. package/dist/8371.js +1 -0
  58. package/dist/8555.js +2 -0
  59. package/dist/8555.js.map +1 -0
  60. package/dist/8618.js +1 -1
  61. package/dist/890.js +1 -1
  62. package/dist/9214.js +1 -1
  63. package/dist/9538.js +1 -1
  64. package/dist/9569.js +1 -1
  65. package/dist/986.js +1 -1
  66. package/dist/9879.js +1 -1
  67. package/dist/9895.js +1 -1
  68. package/dist/9900.js +1 -1
  69. package/dist/9913.js +1 -1
  70. package/dist/main.js +1 -1
  71. package/dist/main.js.map +1 -1
  72. package/dist/openmrs-esm-patient-tests-app.js +1 -1
  73. package/dist/openmrs-esm-patient-tests-app.js.buildmanifest.json +319 -249
  74. package/dist/openmrs-esm-patient-tests-app.js.map +1 -1
  75. package/dist/routes.json +1 -1
  76. package/package.json +3 -3
  77. package/src/edit-test-results/modal/edit-lab-results.modal.tsx +6 -2
  78. package/src/index.ts +1 -1
  79. package/src/routes.json +2 -2
  80. package/src/test-orders/add-test-order/add-test-order.test.tsx +13 -10
  81. package/src/test-orders/add-test-order/add-test-order.workspace.tsx +43 -7
  82. package/src/test-orders/add-test-order/test-order-form.component.tsx +41 -7
  83. package/src/test-orders/add-test-order/test-type-search.component.tsx +56 -8
  84. package/src/test-orders/lab-order-basket-panel/lab-icon.component.tsx +27 -0
  85. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.extension.tsx +62 -15
  86. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.scss +26 -11
  87. package/src/test-orders/lab-order-basket-panel/lab-order-basket-panel.test.tsx +18 -5
  88. package/src/test-results/filter/filter-context.test.tsx +556 -0
  89. package/src/test-results/filter/filter-context.tsx +1 -1
  90. package/src/test-results/filter/filter-reducer.test.ts +540 -0
  91. package/src/test-results/filter/filter-reducer.ts +1 -1
  92. package/src/test-results/filter/filter-set.component.tsx +75 -48
  93. package/src/test-results/filter/filter-set.test.tsx +694 -0
  94. package/src/test-results/filter/filter-types.ts +24 -1
  95. package/src/test-results/grouped-timeline/grid.component.tsx +4 -2
  96. package/src/test-results/grouped-timeline/grouped-timeline.component.tsx +20 -22
  97. package/src/test-results/grouped-timeline/grouped-timeline.test.tsx +52 -2
  98. package/src/test-results/grouped-timeline/reference-range-helpers.test.ts +308 -0
  99. package/src/test-results/grouped-timeline/reference-range-helpers.ts +161 -0
  100. package/src/test-results/grouped-timeline/timeline-data-group.component.tsx +13 -6
  101. package/src/test-results/grouped-timeline/useObstreeData.test.ts +471 -0
  102. package/src/test-results/grouped-timeline/useObstreeData.ts +108 -13
  103. package/src/test-results/individual-results-table/individual-results-table.component.tsx +18 -6
  104. package/src/test-results/individual-results-table/individual-results-table.test.tsx +65 -3
  105. package/src/test-results/individual-results-table-tablet/helper.tsx +8 -2
  106. package/src/test-results/individual-results-table-tablet/individual-results-table-tablet.component.tsx +5 -5
  107. package/src/test-results/individual-results-table-tablet/lab-set-panel.component.tsx +2 -1
  108. package/src/test-results/individual-results-table-tablet/usePanelData.tsx +40 -26
  109. package/src/test-results/loadPatientTestData/helpers.test.ts +834 -0
  110. package/src/test-results/loadPatientTestData/helpers.ts +143 -12
  111. package/src/test-results/loadPatientTestData/loadPatientData.ts +66 -11
  112. package/src/test-results/loadPatientTestData/usePatientResultsData.ts +20 -9
  113. package/src/test-results/overview/common-datatable.component.tsx +1 -1
  114. package/src/test-results/overview/external-overview.extension.tsx +1 -2
  115. package/src/test-results/overview/useOverviewData.ts +22 -10
  116. package/src/test-results/print-modal/print-modal.extension.tsx +1 -1
  117. package/src/test-results/results-viewer/results-viewer.extension.tsx +12 -7
  118. package/src/test-results/tree-view/tree-view.component.tsx +31 -8
  119. package/src/test-results/tree-view/tree-view.test.tsx +119 -2
  120. package/src/test-results/trendline/trendline-resource.tsx +48 -5
  121. package/src/test-results/trendline/trendline.component.tsx +88 -52
  122. package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-data-illustration.tsx +2 -2
  123. package/src/test-results/ui-elements/{resetFiltersEmptyState → reset-filters-empty-state}/filter-empty-state.component.tsx +5 -6
  124. package/src/types.ts +20 -1
  125. package/translations/am.json +3 -4
  126. package/translations/ar.json +3 -4
  127. package/translations/ar_SY.json +3 -4
  128. package/translations/bn.json +3 -4
  129. package/translations/cs.json +119 -0
  130. package/translations/de.json +3 -4
  131. package/translations/en.json +3 -2
  132. package/translations/en_US.json +3 -4
  133. package/translations/es.json +3 -4
  134. package/translations/es_MX.json +3 -4
  135. package/translations/fr.json +5 -6
  136. package/translations/he.json +3 -4
  137. package/translations/hi.json +3 -4
  138. package/translations/hi_IN.json +3 -4
  139. package/translations/id.json +3 -4
  140. package/translations/it.json +3 -4
  141. package/translations/ka.json +3 -4
  142. package/translations/km.json +3 -4
  143. package/translations/ku.json +3 -4
  144. package/translations/ky.json +3 -4
  145. package/translations/lg.json +3 -4
  146. package/translations/ne.json +3 -4
  147. package/translations/pl.json +3 -4
  148. package/translations/pt.json +3 -4
  149. package/translations/pt_BR.json +3 -4
  150. package/translations/qu.json +3 -4
  151. package/translations/ro_RO.json +3 -4
  152. package/translations/ru_RU.json +3 -4
  153. package/translations/si.json +3 -4
  154. package/translations/sq.json +119 -0
  155. package/translations/sw.json +3 -4
  156. package/translations/sw_KE.json +3 -4
  157. package/translations/tr.json +3 -4
  158. package/translations/tr_TR.json +3 -4
  159. package/translations/uk.json +3 -4
  160. package/translations/uz.json +3 -4
  161. package/translations/uz@Latn.json +3 -4
  162. package/translations/uz_UZ.json +3 -4
  163. package/translations/vi.json +3 -4
  164. package/translations/zh.json +3 -4
  165. package/translations/zh_CN.json +3 -4
  166. package/translations/zh_TW.json +119 -0
  167. package/dist/1479.js +0 -1
  168. package/dist/1479.js.map +0 -1
  169. package/dist/2537.js +0 -1
  170. package/dist/2537.js.map +0 -1
  171. package/dist/4918.js +0 -1
  172. package/dist/4918.js.map +0 -1
  173. package/dist/5836.js +0 -2
  174. package/dist/5836.js.map +0 -1
  175. package/dist/6231.js.map +0 -1
  176. package/dist/7053.js +0 -2
  177. package/dist/7053.js.map +0 -1
  178. /package/dist/{7053.js.LICENSE.txt → 8307.js.LICENSE.txt} +0 -0
  179. /package/dist/{5836.js.LICENSE.txt → 8555.js.LICENSE.txt} +0 -0
  180. /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<LowestNode>;
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
- export const Grid: React.FC<{
4
+ interface GridProps {
5
5
  children?: React.ReactNode;
6
6
  style: React.CSSProperties;
7
7
  padding?: boolean;
8
8
  dataColumns: number;
9
- }> = ({ dataColumns, style = {}, padding = false, ...props }) => {
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
- <div className={styles.timelineDataContainer}>
26
- {tableData.map((panel, index) => {
27
- // Filter rowData to only include tests that belong to this panel
28
- const panelTestNames = panel.entries.map((entry) => entry.flatName);
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
- return (
32
- subRows?.length > 0 && (
33
- <div key={index}>
34
- <TimelineDataGroup
35
- groupNumber={index + 1}
36
- parent={{ display: panel.key, flatName: panel.key }}
37
- patientUuid={patientUuid}
38
- setXScroll={setXScroll}
39
- subRows={subRows}
40
- xScroll={xScroll}
41
- />
42
- </div>
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
- expect(screen.getByText('umol/L')).toBeInTheDocument();
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
+ }