@ovok/core 0.3.16 → 0.3.17

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.
@@ -2,9 +2,16 @@ import { Observation } from "@medplum/fhirtypes";
2
2
  import { BloodGlucoseMeasurement, BloodPressureMeasurement, BodyTemperatureMeasurement, BodyWeightMeasurement, EcgMeasurement, HeartRateMeasurement, HeartRateVariabilityMeasurement, Measurement, MeasurementTypeKey, PulseOximeterMeasurement, RespiratoryRateMeasurement, RestingHeartRateMeasurement, StepCountMeasurement, UrineAnalyzeMeasurement, Vo2MaxMeasurement, WalkingHeartRateAverageMeasurement } from "../../../types";
3
3
  export type SupportedMeasurement = BloodGlucoseMeasurement | BloodPressureMeasurement | BodyTemperatureMeasurement | BodyWeightMeasurement | EcgMeasurement | HeartRateMeasurement | HeartRateVariabilityMeasurement | PulseOximeterMeasurement | RespiratoryRateMeasurement | RestingHeartRateMeasurement | StepCountMeasurement | UrineAnalyzeMeasurement | Vo2MaxMeasurement | WalkingHeartRateAverageMeasurement | Measurement;
4
4
  type SupportedType = Exclude<MeasurementTypeKey, MeasurementTypeKey.symptomQuestionnaire>;
5
+ export type ObservationGrouping = "hasMember" | "time" | ((observation: Observation, index: number, observations: readonly Observation[]) => string);
6
+ export interface ObservationsToMeasurementsOptions {
7
+ /** How related Observation resources should be reconstructed into measurements. */
8
+ groupBy?: ObservationGrouping;
9
+ /** Maximum timestamp difference used by the time-based fallback. */
10
+ timeToleranceMs?: number;
11
+ }
5
12
  export declare const observationCodesForTypes: (types: readonly SupportedType[]) => string;
6
13
  /** Converts FHIR observations back into SDK measurements without patient/app assumptions. */
7
- export declare const observationsToMeasurements: <T extends readonly SupportedType[]>(observations: readonly Observation[], types: T) => Array<Extract<SupportedMeasurement, {
14
+ export declare const observationsToMeasurements: <T extends readonly SupportedType[]>(observations: readonly Observation[], types: T, options?: ObservationsToMeasurementsOptions) => Array<Extract<SupportedMeasurement, {
8
15
  measurementTypeKey: T[number];
9
16
  }>>;
10
17
  export {};
@@ -3,6 +3,7 @@
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.observationsToMeasurements = exports.observationCodesForTypes = void 0;
5
5
  const types_1 = require("../../../types");
6
+ const DEFAULT_OBSERVATION_TIME_TOLERANCE_MS = 1000;
6
7
  const codeOf = (observation) => {
7
8
  var _a, _b, _c, _d, _e, _f, _g;
8
9
  return (_d = (_c = (_b = (_a = observation.code) === null || _a === void 0 ? void 0 : _a.coding) === null || _b === void 0 ? void 0 : _b.find((coding) => coding.system === "http://loinc.org")) === null || _c === void 0 ? void 0 : _c.code) !== null && _d !== void 0 ? _d : (_g = (_f = (_e = observation.code) === null || _e === void 0 ? void 0 : _e.coding) === null || _f === void 0 ? void 0 : _f[0]) === null || _g === void 0 ? void 0 : _g.code;
@@ -50,28 +51,138 @@ const ecgGroupKey = (observation, observations) => {
50
51
  });
51
52
  return root ? groupKey(root) : groupKey(observation);
52
53
  };
53
- const flattenMembers = (observations) => {
54
- const byReference = new Map(observations
55
- .filter((observation) => observation.id)
56
- .map((observation) => [`Observation/${observation.id}`, observation]));
57
- const flattened = [];
58
- const add = (observation) => {
54
+ const referenceKeys = (reference) => {
55
+ const id = reference.replace(/^urn:uuid:/, "").replace(/^Observation\//, "");
56
+ return [reference, id, `Observation/${id}`, `urn:uuid:${id}`];
57
+ };
58
+ const observationGroups = (observations) => {
59
+ const byReference = new Map();
60
+ observations.forEach((observation, index) => {
61
+ if (observation.id) {
62
+ referenceKeys(observation.id).forEach((key) => byReference.set(key, index));
63
+ }
64
+ });
65
+ const parent = observations.map((_, index) => index);
66
+ const find = (index) => {
67
+ if (parent[index] !== index) {
68
+ parent[index] = find(parent[index]);
69
+ }
70
+ return parent[index];
71
+ };
72
+ const union = (left, right) => {
73
+ const leftRoot = find(left);
74
+ const rightRoot = find(right);
75
+ if (leftRoot !== rightRoot) {
76
+ parent[rightRoot] = leftRoot;
77
+ }
78
+ };
79
+ const linked = new Set();
80
+ observations.forEach((observation, index) => {
59
81
  var _a;
60
- flattened.push(observation);
61
82
  (_a = observation.hasMember) === null || _a === void 0 ? void 0 : _a.forEach((reference) => {
62
- const member = reference.reference ? byReference.get(reference.reference) : undefined;
63
- if (member) {
64
- add(member);
83
+ const referenced = reference.reference
84
+ ? referenceKeys(reference.reference)
85
+ .map((key) => byReference.get(key))
86
+ .find((candidate) => candidate !== undefined)
87
+ : undefined;
88
+ if (referenced !== undefined) {
89
+ linked.add(index);
90
+ linked.add(referenced);
91
+ union(index, referenced);
65
92
  }
66
93
  });
94
+ });
95
+ return {
96
+ componentOf: new Map(observations.map((_, index) => [index, find(index)])),
97
+ linked,
67
98
  };
68
- observations.filter((observation) => { var _a; return !((_a = observation.hasMember) === null || _a === void 0 ? void 0 : _a.length); }).forEach(add);
69
- observations.filter((observation) => { var _a; return (_a = observation.hasMember) === null || _a === void 0 ? void 0 : _a.length; }).forEach((observation) => {
70
- if (!flattened.includes(observation)) {
71
- add(observation);
99
+ };
100
+ const addToGroup = (groups, key, observation, index) => {
101
+ const group = groups.get(key);
102
+ if (group) {
103
+ group.observations.push(observation);
104
+ }
105
+ else {
106
+ groups.set(key, { firstIndex: index, observations: [observation] });
107
+ }
108
+ };
109
+ const groupByTime = (observations, timeToleranceMs) => {
110
+ const groups = [];
111
+ observations.forEach((observation, index) => {
112
+ var _a;
113
+ const recordedAt = (_a = recordedAtOf(observation)) === null || _a === void 0 ? void 0 : _a.getTime();
114
+ const group = recordedAt === undefined
115
+ ? undefined
116
+ : groups.find((candidate) => candidate.anchor !== undefined &&
117
+ Math.abs(candidate.anchor - recordedAt) <= timeToleranceMs);
118
+ if (group) {
119
+ group.observations.push(observation);
120
+ return;
72
121
  }
122
+ groups.push({
123
+ firstIndex: index,
124
+ ...(recordedAt !== undefined ? { anchor: recordedAt } : {}),
125
+ observations: [observation],
126
+ });
73
127
  });
74
- return flattened;
128
+ return groups;
129
+ };
130
+ const groupObservations = (observations, options, legacyKey) => {
131
+ var _a, _b;
132
+ const grouping = (_a = options.groupBy) !== null && _a !== void 0 ? _a : "hasMember";
133
+ const tolerance = Math.max(0, (_b = options.timeToleranceMs) !== null && _b !== void 0 ? _b : DEFAULT_OBSERVATION_TIME_TOLERANCE_MS);
134
+ if (grouping === "time") {
135
+ return groupByTime(observations, tolerance);
136
+ }
137
+ if (typeof grouping === "function") {
138
+ const groups = new Map();
139
+ observations.forEach((observation, index) => {
140
+ addToGroup(groups, grouping(observation, index, observations), observation, index);
141
+ });
142
+ return [...groups.values()].sort((left, right) => left.firstIndex - right.firstIndex);
143
+ }
144
+ const { componentOf, linked } = observationGroups(observations);
145
+ const componentSizes = new Map();
146
+ componentOf.forEach((root) => {
147
+ var _a;
148
+ componentSizes.set(root, ((_a = componentSizes.get(root)) !== null && _a !== void 0 ? _a : 0) + 1);
149
+ });
150
+ const groups = new Map();
151
+ const fallback = observations.filter((_, index) => {
152
+ var _a;
153
+ const root = componentOf.get(index);
154
+ return ((_a = componentSizes.get(root)) !== null && _a !== void 0 ? _a : 0) === 1;
155
+ });
156
+ observations.forEach((observation, index) => {
157
+ var _a;
158
+ const root = componentOf.get(index);
159
+ if (((_a = componentSizes.get(root)) !== null && _a !== void 0 ? _a : 0) > 1) {
160
+ addToGroup(groups, `member:${root}`, observation, index);
161
+ }
162
+ });
163
+ if (fallback.length > 0) {
164
+ const fallbackIndexes = new Set(fallback.map((observation) => observations.indexOf(observation)));
165
+ const unlinked = observations.filter((_, index) => fallbackIndexes.has(index) && !linked.has(index));
166
+ const unresolved = observations.filter((_, index) => fallbackIndexes.has(index) && linked.has(index));
167
+ groupByTime(unlinked, tolerance).forEach((group) => {
168
+ const first = group.observations[0];
169
+ const firstIndex = observations.indexOf(first);
170
+ const key = legacyKey
171
+ ? `legacy:${legacyKey(first, firstIndex)}`
172
+ : `time:${firstIndex}`;
173
+ addToGroup(groups, key, first, firstIndex);
174
+ group.observations.slice(1).forEach((observation) => {
175
+ const index = observations.indexOf(observation);
176
+ addToGroup(groups, key, observation, index);
177
+ });
178
+ });
179
+ unresolved.forEach((observation) => {
180
+ var _a;
181
+ const index = observations.indexOf(observation);
182
+ addToGroup(groups, `unresolved:${(_a = observation.id) !== null && _a !== void 0 ? _a : index}`, observation, index);
183
+ });
184
+ }
185
+ return [...groups.values()].sort((left, right) => left.firstIndex - right.firstIndex);
75
186
  };
76
187
  const firstValue = (observations, code) => observations.find((observation) => codeOf(observation) === code)
77
188
  ? valueOf(observations.find((observation) => codeOf(observation) === code))
@@ -82,7 +193,11 @@ const baseMeasurement = (measurementTypeKey, observation) => ({
82
193
  });
83
194
  const inverseGroup = (type, observations) => {
84
195
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
85
- const first = observations[0];
196
+ const first = [...observations].sort((left, right) => {
197
+ var _a, _b, _c, _d;
198
+ return ((_b = (_a = recordedAtOf(left)) === null || _a === void 0 ? void 0 : _a.getTime()) !== null && _b !== void 0 ? _b : Number.MAX_SAFE_INTEGER) -
199
+ ((_d = (_c = recordedAtOf(right)) === null || _c === void 0 ? void 0 : _c.getTime()) !== null && _d !== void 0 ? _d : Number.MAX_SAFE_INTEGER);
200
+ })[0];
86
201
  const measurement = baseMeasurement(type, first);
87
202
  const value = (code) => firstValue(observations, code);
88
203
  switch (type) {
@@ -225,22 +340,16 @@ const codesForType = {
225
340
  const observationCodesForTypes = (types) => [...new Set(types.flatMap((type) => { var _a; return (_a = codesForType[type]) !== null && _a !== void 0 ? _a : []; }))].join(",");
226
341
  exports.observationCodesForTypes = observationCodesForTypes;
227
342
  /** Converts FHIR observations back into SDK measurements without patient/app assumptions. */
228
- const observationsToMeasurements = (observations, types) => {
229
- const source = flattenMembers(observations);
343
+ const observationsToMeasurements = (observations, types, options = {}) => {
230
344
  const output = [];
231
345
  types.forEach((type) => {
232
346
  var _a;
233
347
  const codes = (_a = codesForType[type]) !== null && _a !== void 0 ? _a : [];
234
- const matching = source.filter((observation) => { var _a; return codes.includes((_a = codeOf(observation)) !== null && _a !== void 0 ? _a : ""); });
235
- const groups = new Map();
236
- matching.forEach((observation) => {
237
- var _a, _b, _c;
238
- const key = type === types_1.MeasurementTypeKey.ecg
239
- ? ecgGroupKey(observation, matching)
240
- : (_b = (_a = recordedAtOf(observation)) === null || _a === void 0 ? void 0 : _a.toISOString()) !== null && _b !== void 0 ? _b : "unknown";
241
- groups.set(key, [...((_c = groups.get(key)) !== null && _c !== void 0 ? _c : []), observation]);
242
- });
243
- groups.forEach((group) => output.push(inverseGroup(type, group)));
348
+ const matching = observations.filter((observation) => { var _a; return codes.includes((_a = codeOf(observation)) !== null && _a !== void 0 ? _a : ""); });
349
+ const groups = groupObservations(matching, options, type === types_1.MeasurementTypeKey.ecg
350
+ ? (observation, index) => ecgGroupKey(observation, matching) || `ecg:${index}`
351
+ : undefined);
352
+ groups.forEach((group) => output.push(inverseGroup(type, group.observations)));
244
353
  });
245
354
  return output;
246
355
  };
@@ -24,6 +24,34 @@ const linkObservationMembers = (observations) => {
24
24
  });
25
25
  };
26
26
  exports.linkObservationMembers = linkObservationMembers;
27
+ const createdObservationReference = (response, index) => {
28
+ var _a, _b, _c, _d;
29
+ const entry = (_a = response.entry) === null || _a === void 0 ? void 0 : _a[index];
30
+ const id = ((_b = entry === null || entry === void 0 ? void 0 : entry.resource) === null || _b === void 0 ? void 0 : _b.resourceType) === "Observation"
31
+ ? entry.resource.id
32
+ : undefined;
33
+ if (id) {
34
+ return `Observation/${id}`;
35
+ }
36
+ const location = (_d = (_c = entry === null || entry === void 0 ? void 0 : entry.response) === null || _c === void 0 ? void 0 : _c.location) === null || _d === void 0 ? void 0 : _d.split("?")[0];
37
+ const match = location === null || location === void 0 ? void 0 : location.match(/(?:^|\/)(Observation\/[^/]+)(?:\/_history\/[^/]+)?$/);
38
+ return match === null || match === void 0 ? void 0 : match[1];
39
+ };
40
+ const rewriteObservationReference = (entry, from, to) => {
41
+ var _a;
42
+ const resource = entry.resource;
43
+ if ((resource === null || resource === void 0 ? void 0 : resource.resourceType) !== "Observation" || !((_a = resource.hasMember) === null || _a === void 0 ? void 0 : _a.length)) {
44
+ return entry;
45
+ }
46
+ return {
47
+ ...entry,
48
+ resource: {
49
+ ...resource,
50
+ hasMember: resource.hasMember.map((reference) => reference.reference === from ? { ...reference, reference: to } : reference),
51
+ },
52
+ };
53
+ };
54
+ const rewriteObservationReferences = (entries, from, to) => entries.map((entry) => rewriteObservationReference(entry, from, to));
27
55
  const splitObservationBundleEntries = (bundle) => {
28
56
  var _a;
29
57
  const entries = (_a = bundle.entry) !== null && _a !== void 0 ? _a : [];
@@ -50,26 +78,49 @@ const splitObservationBundleEntries = (bundle) => {
50
78
  };
51
79
  exports.splitObservationBundleEntries = splitObservationBundleEntries;
52
80
  const executeObservationBundle = async (bundle, executeBatch) => {
53
- var _a, _b, _c, _d;
81
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
54
82
  const entries = (_a = bundle.entry) !== null && _a !== void 0 ? _a : [];
55
83
  if (bundle.type === "transaction" &&
56
84
  entries.length > exports.MAX_CONDITIONAL_OBSERVATION_ENTRIES) {
85
+ const rootReference = (_c = (_b = entries[0]) === null || _b === void 0 ? void 0 : _b.fullUrl) !== null && _c !== void 0 ? _c : (((_e = (_d = entries[0]) === null || _d === void 0 ? void 0 : _d.resource) === null || _e === void 0 ? void 0 : _e.resourceType) === "Observation"
86
+ ? `urn:uuid:${(0, offline_1.stableMeasurementId)(entries[0].resource)}`
87
+ : undefined);
57
88
  const transactionResponse = await (0, exports.executeObservationBundle)({ ...bundle, entry: entries.slice(0, 1) }, executeBatch);
58
- const batchResponse = await (0, exports.executeObservationBundle)({ ...bundle, type: "batch", entry: entries.slice(1) }, executeBatch);
89
+ const savedRootReference = rootReference
90
+ ? createdObservationReference(transactionResponse, 0)
91
+ : undefined;
92
+ const batchResponse = await (0, exports.executeObservationBundle)({
93
+ ...bundle,
94
+ type: "batch",
95
+ entry: savedRootReference
96
+ ? rewriteObservationReferences(entries.slice(1), rootReference, savedRootReference)
97
+ : entries.slice(1),
98
+ }, executeBatch);
59
99
  return {
60
100
  resourceType: "Bundle",
61
101
  type: "batch-response",
62
102
  entry: [
63
- ...((_b = transactionResponse.entry) !== null && _b !== void 0 ? _b : []),
64
- ...((_c = batchResponse.entry) !== null && _c !== void 0 ? _c : []),
103
+ ...((_f = transactionResponse.entry) !== null && _f !== void 0 ? _f : []),
104
+ ...((_g = batchResponse.entry) !== null && _g !== void 0 ? _g : []),
65
105
  ],
66
106
  };
67
107
  }
68
108
  const chunks = (0, exports.splitObservationBundleEntries)(bundle);
69
109
  if (chunks) {
70
110
  const responses = [];
71
- for (const entry of chunks) {
72
- responses.push(await (0, exports.executeObservationBundle)({ ...bundle, entry }, executeBatch));
111
+ const rootReference = (_j = (_h = entries[0]) === null || _h === void 0 ? void 0 : _h.fullUrl) !== null && _j !== void 0 ? _j : (((_l = (_k = entries[0]) === null || _k === void 0 ? void 0 : _k.resource) === null || _l === void 0 ? void 0 : _l.resourceType) === "Observation"
112
+ ? `urn:uuid:${(0, offline_1.stableMeasurementId)(entries[0].resource)}`
113
+ : undefined);
114
+ let savedRootReference;
115
+ for (const [index, entry] of chunks.entries()) {
116
+ const chunkEntries = index > 0 && rootReference && savedRootReference
117
+ ? rewriteObservationReferences(entry, rootReference, savedRootReference)
118
+ : entry;
119
+ const response = await (0, exports.executeObservationBundle)({ ...bundle, entry: chunkEntries }, executeBatch);
120
+ if (index === 0 && rootReference) {
121
+ savedRootReference = createdObservationReference(response, 0);
122
+ }
123
+ responses.push(response);
73
124
  }
74
125
  return {
75
126
  resourceType: "Bundle",
@@ -78,7 +129,7 @@ const executeObservationBundle = async (bundle, executeBatch) => {
78
129
  };
79
130
  }
80
131
  const response = await executeBatch(bundle);
81
- if ((_d = response.entry) === null || _d === void 0 ? void 0 : _d.some((entry) => {
132
+ if ((_m = response.entry) === null || _m === void 0 ? void 0 : _m.some((entry) => {
82
133
  var _a, _b;
83
134
  const status = (_b = (_a = entry.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : "";
84
135
  return status.startsWith("4") || status.startsWith("5");
@@ -48,7 +48,7 @@ exports.OVOK_CORE_REQUIREMENTS = {
48
48
  description: "The FHIR surface a server must provide for the @ovok/core client library's own methods to function.",
49
49
  software: {
50
50
  name: "@ovok/core",
51
- version: "0.3.16",
51
+ version: "0.3.17",
52
52
  },
53
53
  fhirVersion: "4.0.1",
54
54
  format: ["json"],
@@ -1,4 +1,5 @@
1
1
  import { Observation } from "@medplum/fhirtypes";
2
+ import type { ObservationsToMeasurementsOptions } from "../client/observation/methods/observationsToMeasurements";
2
3
  import { observationsToMeasurements, SupportedMeasurement } from "../client/observation/methods/observationsToMeasurements";
3
4
  import { GenerateObservationBodyParams } from "../client/observation/types/generate-observation-body/GenerateObservationBodyParams";
4
5
  import { OvokClient } from "../client/ovok-client";
@@ -12,10 +13,12 @@ export interface UseObservationsOptions<T extends readonly SupportedType[]> {
12
13
  types: T;
13
14
  period?: ObservationPeriod;
14
15
  patientId?: string;
16
+ groupBy?: ObservationsToMeasurementsOptions["groupBy"];
17
+ timeToleranceMs?: number;
15
18
  }
16
19
  /** Plain inverse mapping for apps that already fetched their Observation resources. */
17
20
  export { observationsToMeasurements };
18
- export declare const useObservations: <T extends readonly SupportedType[]>({ types, period, patientId, }: UseObservationsOptions<T>) => {
21
+ export declare const useObservations: <T extends readonly SupportedType[]>({ types, period, patientId, groupBy, timeToleranceMs, }: UseObservationsOptions<T>) => {
19
22
  measurements: Array<Extract<SupportedMeasurement, {
20
23
  measurementTypeKey: T[number];
21
24
  }>>;
@@ -41,7 +41,7 @@ const observationsToMeasurements_1 = require("../client/observation/methods/obse
41
41
  Object.defineProperty(exports, "observationsToMeasurements", { enumerable: true, get: function () { return observationsToMeasurements_1.observationsToMeasurements; } });
42
42
  const types_1 = require("../types");
43
43
  const index_1 = require("./index");
44
- const useObservations = ({ types, period, patientId, }) => {
44
+ const useObservations = ({ types, period, patientId, groupBy, timeToleranceMs, }) => {
45
45
  var _a, _b, _c;
46
46
  // A broad search is intentional: one measurement can expand into several
47
47
  // observations, and the inverse mapper performs the type-specific grouping.
@@ -60,7 +60,7 @@ const useObservations = ({ types, period, patientId, }) => {
60
60
  }), [codes, patientId, period === null || period === void 0 ? void 0 : period.end, period === null || period === void 0 ? void 0 : period.start]);
61
61
  const [resources, loading, outcome] = (0, react_hooks_1.useSearchResources)("Observation", query);
62
62
  const observations = resources !== null && resources !== void 0 ? resources : [];
63
- const measurements = React.useMemo(() => (0, observationsToMeasurements_1.observationsToMeasurements)(observations, types), [observations, types]);
63
+ const measurements = React.useMemo(() => (0, observationsToMeasurements_1.observationsToMeasurements)(observations, types, { groupBy, timeToleranceMs }), [groupBy, observations, timeToleranceMs, types]);
64
64
  return {
65
65
  measurements,
66
66
  loading,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovok/core",
3
- "version": "0.3.16",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",