fhir-data-utils-ts 0.3.7 → 0.4.0
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/.codex/skills/govern-fhir-observation-components/SKILL.md +11 -0
- package/CHANGELOG.md +11 -0
- package/README.md +9 -0
- package/dist/claims/composition-claims.d.ts +1 -0
- package/dist/claims/composition-claims.js +1 -0
- package/dist/converters/convert-composition.js +12 -2
- package/dist/observation-claims.d.ts +3 -0
- package/dist/observation-claims.js +3 -0
- package/dist/vital-sign-observations.d.ts +75 -1
- package/dist/vital-sign-observations.js +225 -9
- package/docs/VITAL_SIGN_DAILY_DOCUMENT.md +79 -0
- package/package.json +1 -1
|
@@ -53,6 +53,17 @@ description: Implement or audit neutral FHIR Observation claim catalogs, vital-s
|
|
|
53
53
|
copy `Observation.*`, LOINC, UCUM or category strings into portals,
|
|
54
54
|
assistants, tests or snippets.
|
|
55
55
|
- Keep displays separate from `system|code` tokens and local-language text.
|
|
56
|
+
- For repeated vital signs, keep every raw reading as `registered`, require the
|
|
57
|
+
configured cadence before deriving a `preliminary` mean, and preserve the
|
|
58
|
+
source links through `Observation.derived-from`.
|
|
59
|
+
- Persist the EventTiming bucket through the canonical
|
|
60
|
+
`Observation.date-when` claim. Do not infer MORN/EVE from a server time zone.
|
|
61
|
+
- Build one preliminary Composition per explicit local date and IANA time
|
|
62
|
+
zone. Only authorized confirmation returns a final daily document with a
|
|
63
|
+
professional attester and final top-level Observations.
|
|
64
|
+
- Keep ledger hashing, individual-index writes, Digital Twin materialization
|
|
65
|
+
and research eligibility outside this neutral package. A final graph is data
|
|
66
|
+
state, not authorization for those side effects.
|
|
56
67
|
|
|
57
68
|
## Change with TDD
|
|
58
69
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-09-27
|
|
4
|
+
|
|
5
|
+
- Add the neutral daily vital-sign document lifecycle: registered raw readings,
|
|
6
|
+
preliminary three-reading means, MORN/EVE EventTiming, one preliminary daily
|
|
7
|
+
Composition and explicit professional finalization.
|
|
8
|
+
- Preserve mean provenance through `Observation.derived-from`, validate the
|
|
9
|
+
local calendar day with an IANA time zone and project Composition status plus
|
|
10
|
+
section entry references at the FHIR R4 boundary.
|
|
11
|
+
- Add governed UCUM Fahrenheit and avoirdupois-pound input alongside Celsius
|
|
12
|
+
and kilograms without moving regional UI preferences into shared FHIR data.
|
|
13
|
+
|
|
3
14
|
## 0.3.7 - 2026-09-26
|
|
4
15
|
|
|
5
16
|
- Add claims-first FHIR contracts for Location-linked Encounter care journeys,
|
package/README.md
CHANGED
|
@@ -105,6 +105,15 @@ without inventing a new flat-claim family for each scale.
|
|
|
105
105
|
`Observation.user-selected` provenance flag. Consumers submit its `entries`
|
|
106
106
|
without reconstructing component claims or relationships.
|
|
107
107
|
|
|
108
|
+
Repeated morning/evening capture uses `buildRepeatedVitalSignAverage`: three
|
|
109
|
+
two-minute raw readings remain `registered`, while their derived arithmetic
|
|
110
|
+
mean is `preliminary`. `buildVitalSignDailyDocument` groups the day's top-level
|
|
111
|
+
Observations under one preliminary Composition; only
|
|
112
|
+
`finalizeVitalSignDailyDocument` returns a final Composition and final
|
|
113
|
+
top-level Observations with a professional attester. Ledger hashing, index
|
|
114
|
+
updates, Digital Twin and research eligibility remain owning-service policy.
|
|
115
|
+
See [Daily vital-sign document lifecycle](docs/VITAL_SIGN_DAILY_DOCUMENT.md).
|
|
116
|
+
|
|
108
117
|
See [the UHC FHIR utilities migration](docs/UHC_FHIR_UTILS_MIGRATION.md) for the
|
|
109
118
|
reuse/rewrite/retire inventory and the rule for moving frozen contracts without
|
|
110
119
|
changing any `gdc-*` repository.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Copyright 2026 Conéctate Soluciones y Aplicaciones SL under the Apache License, Version 2.0.
|
|
2
2
|
// File: src/models/interoperable-claims/composition-claims.ts
|
|
3
3
|
export const CompositionClaim = {
|
|
4
|
+
Status: 'Composition.status',
|
|
4
5
|
Subject: 'Composition.subject',
|
|
5
6
|
Section: 'Composition.section',
|
|
6
7
|
Author: 'Composition.author',
|
|
@@ -31,6 +31,7 @@ function alignedAttesterValue(attesters, select) {
|
|
|
31
31
|
export function compositionFlatToFhirR4(claims) {
|
|
32
32
|
return {
|
|
33
33
|
resourceType: 'Composition',
|
|
34
|
+
status: claims[CompositionClaim.Status],
|
|
34
35
|
identifier: claims[CompositionClaim.Identifier] ? { value: claims[CompositionClaim.Identifier] } : undefined,
|
|
35
36
|
subject: claims[CompositionClaim.Subject] ? { reference: claims[CompositionClaim.Subject] } : undefined,
|
|
36
37
|
author: claims[CompositionClaim.Author] ? claims[CompositionClaim.Author].split(',').map((reference) => ({ reference: reference.trim() })) : undefined,
|
|
@@ -41,7 +42,9 @@ export function compositionFlatToFhirR4(claims) {
|
|
|
41
42
|
type: claims[CompositionClaim.Type] ? { coding: codingFromValue(claims[CompositionClaim.Type]) } : undefined,
|
|
42
43
|
section: claims[CompositionClaim.Section]
|
|
43
44
|
? claims[CompositionClaim.Section].split(',').map((value) => ({ code: { coding: codingFromValue(value.trim()) } }))
|
|
44
|
-
:
|
|
45
|
+
: claims[CompositionClaim.Entry]
|
|
46
|
+
? [{ entry: claims[CompositionClaim.Entry].split(',').map((reference) => ({ reference: reference.trim() })) }]
|
|
47
|
+
: undefined,
|
|
45
48
|
};
|
|
46
49
|
}
|
|
47
50
|
export function compositionFhirR4ToFlat(resource) {
|
|
@@ -53,7 +56,14 @@ export function compositionFhirR4ToFlat(resource) {
|
|
|
53
56
|
.map((item) => codingToValue(item?.code?.coding?.[0]))
|
|
54
57
|
.filter((item) => Boolean(item))
|
|
55
58
|
: [];
|
|
59
|
+
const entryReferences = Array.isArray(resource.section)
|
|
60
|
+
? resource.section
|
|
61
|
+
.flatMap((section) => Array.isArray(section.entry) ? section.entry : [])
|
|
62
|
+
.map((entry) => referenceToValue(entry))
|
|
63
|
+
.filter((item) => Boolean(item))
|
|
64
|
+
: [];
|
|
56
65
|
return {
|
|
66
|
+
[CompositionClaim.Status]: resource.status,
|
|
57
67
|
[CompositionClaim.Subject]: referenceToValue(resource.subject),
|
|
58
68
|
[CompositionClaim.Section]: sectionValues.length ? sectionValues.join(',') : undefined,
|
|
59
69
|
[CompositionClaim.Author]: referenceListToCsv(resource.author),
|
|
@@ -62,7 +72,7 @@ export function compositionFhirR4ToFlat(resource) {
|
|
|
62
72
|
[CompositionClaim.AttesterTime]: alignedAttesterValue(attesters, (attester) => attester.time),
|
|
63
73
|
[CompositionClaim.Custodian]: referenceToValue(resource.custodian),
|
|
64
74
|
[CompositionClaim.Date]: resource.date,
|
|
65
|
-
[CompositionClaim.Entry]: undefined,
|
|
75
|
+
[CompositionClaim.Entry]: entryReferences.length ? entryReferences.join(',') : undefined,
|
|
66
76
|
[CompositionClaim.Type]: codingToValue(resource.type?.coding?.[0]),
|
|
67
77
|
[CompositionClaim.Identifier]: resource.identifier?.value,
|
|
68
78
|
[CompositionClaim.Title]: resource.title,
|
|
@@ -85,6 +85,7 @@ export declare const ObservationClaim: Readonly<{
|
|
|
85
85
|
readonly Code: "Observation.code";
|
|
86
86
|
readonly Date: "Observation.date";
|
|
87
87
|
readonly Device: "Observation.device";
|
|
88
|
+
readonly DerivedFrom: "Observation.derived-from";
|
|
88
89
|
readonly Encounter: "Observation.encounter";
|
|
89
90
|
readonly Focus: "Observation.focus";
|
|
90
91
|
readonly HasMember: "Observation.has-member";
|
|
@@ -122,6 +123,8 @@ export declare const ObservationClaim: Readonly<{
|
|
|
122
123
|
readonly ValueString: "Observation.value-string";
|
|
123
124
|
readonly Note: "Observation.note";
|
|
124
125
|
readonly EffectiveDateTime: "Observation.effective-datetime";
|
|
126
|
+
/** FHIR EventTiming bucket used by recurring morning/evening capture. */
|
|
127
|
+
readonly DateWhen: "Observation.date-when";
|
|
125
128
|
/** @deprecated Read-only camelCase key from historical persisted rows. */
|
|
126
129
|
readonly EffectiveDateTimeLegacy: "Observation.effectiveDateTime";
|
|
127
130
|
/** Custom provenance flag for an explicitly entered or selected result. */
|
|
@@ -36,6 +36,7 @@ export const ObservationClaim = Object.freeze({
|
|
|
36
36
|
Code: 'Observation.code',
|
|
37
37
|
Date: 'Observation.date',
|
|
38
38
|
Device: 'Observation.device',
|
|
39
|
+
DerivedFrom: 'Observation.derived-from',
|
|
39
40
|
Encounter: 'Observation.encounter',
|
|
40
41
|
Focus: 'Observation.focus',
|
|
41
42
|
HasMember: 'Observation.has-member',
|
|
@@ -73,6 +74,8 @@ export const ObservationClaim = Object.freeze({
|
|
|
73
74
|
ValueString: 'Observation.value-string',
|
|
74
75
|
Note: 'Observation.note',
|
|
75
76
|
EffectiveDateTime: 'Observation.effective-datetime',
|
|
77
|
+
/** FHIR EventTiming bucket used by recurring morning/evening capture. */
|
|
78
|
+
DateWhen: 'Observation.date-when',
|
|
76
79
|
/** @deprecated Read-only camelCase key from historical persisted rows. */
|
|
77
80
|
EffectiveDateTimeLegacy: 'Observation.effectiveDateTime',
|
|
78
81
|
/** Custom provenance flag for an explicitly entered or selected result. */
|
|
@@ -71,6 +71,12 @@ export declare const VitalSignsUnits: Readonly<{
|
|
|
71
71
|
display?: string;
|
|
72
72
|
claim: string;
|
|
73
73
|
}>;
|
|
74
|
+
readonly Fahrenheit: Readonly<{
|
|
75
|
+
system: string;
|
|
76
|
+
code: string;
|
|
77
|
+
display?: string;
|
|
78
|
+
claim: string;
|
|
79
|
+
}>;
|
|
74
80
|
readonly Percent: Readonly<{
|
|
75
81
|
system: string;
|
|
76
82
|
code: string;
|
|
@@ -83,6 +89,12 @@ export declare const VitalSignsUnits: Readonly<{
|
|
|
83
89
|
display?: string;
|
|
84
90
|
claim: string;
|
|
85
91
|
}>;
|
|
92
|
+
readonly Pound: Readonly<{
|
|
93
|
+
system: string;
|
|
94
|
+
code: string;
|
|
95
|
+
display?: string;
|
|
96
|
+
claim: string;
|
|
97
|
+
}>;
|
|
86
98
|
}>;
|
|
87
99
|
/** Governed FHIR/LOINC tokens used by every channel that captures vital signs. */
|
|
88
100
|
export declare const VitalSignCode: Readonly<{
|
|
@@ -99,19 +111,24 @@ export declare const VitalSignCode: Readonly<{
|
|
|
99
111
|
export declare const VitalSignUnit: Readonly<{
|
|
100
112
|
readonly bloodPressure: string;
|
|
101
113
|
readonly temperature: string;
|
|
114
|
+
readonly temperatureFahrenheit: string;
|
|
102
115
|
readonly heartRate: string;
|
|
103
116
|
readonly oxygenSaturation: string;
|
|
104
117
|
readonly respiratoryRate: string;
|
|
105
118
|
readonly bodyWeight: string;
|
|
119
|
+
readonly bodyWeightPound: string;
|
|
106
120
|
}>;
|
|
121
|
+
export type VitalSignQuantityUnit = typeof VitalSignUnit[keyof typeof VitalSignUnit];
|
|
107
122
|
export type VitalSignKind = 'blood-pressure' | 'temperature' | 'heart-rate' | 'oxygen-saturation' | 'respiratory-rate' | 'body-weight';
|
|
108
|
-
export type VitalSignObservationStatus = 'preliminary' | 'final';
|
|
123
|
+
export type VitalSignObservationStatus = 'registered' | 'preliminary' | 'final';
|
|
124
|
+
export type VitalSignEventTiming = 'MORN' | 'AFT' | 'EVE' | 'NIGHT';
|
|
109
125
|
type CommonMeasurement = Readonly<{
|
|
110
126
|
entryId: string;
|
|
111
127
|
subjectReference: string;
|
|
112
128
|
kind: VitalSignKind;
|
|
113
129
|
measuredAt: string;
|
|
114
130
|
status: VitalSignObservationStatus;
|
|
131
|
+
eventTiming?: VitalSignEventTiming;
|
|
115
132
|
deviceReference?: string;
|
|
116
133
|
note?: string;
|
|
117
134
|
userSelected?: boolean;
|
|
@@ -123,6 +140,7 @@ export type VitalSignMeasurement = CommonMeasurement & (Readonly<{
|
|
|
123
140
|
}> | Readonly<{
|
|
124
141
|
kind: Exclude<VitalSignKind, 'blood-pressure'>;
|
|
125
142
|
value: number;
|
|
143
|
+
unit?: VitalSignQuantityUnit;
|
|
126
144
|
}>);
|
|
127
145
|
/**
|
|
128
146
|
* Canonical claims-first representation of one native FHIR Observation.
|
|
@@ -218,6 +236,7 @@ export type VitalSignDeviceReading = Readonly<{
|
|
|
218
236
|
kind: Exclude<VitalSignKind, 'blood-pressure'>;
|
|
219
237
|
measuredAt: string;
|
|
220
238
|
value: number;
|
|
239
|
+
unit?: VitalSignQuantityUnit;
|
|
221
240
|
note?: string;
|
|
222
241
|
}>;
|
|
223
242
|
/** Converts a device/API batch to independent preliminary Observations without aggregation. */
|
|
@@ -226,6 +245,61 @@ export declare function normalizeVitalSignDeviceBatch(input: Readonly<{
|
|
|
226
245
|
deviceReference: string;
|
|
227
246
|
readings: readonly VitalSignDeviceReading[];
|
|
228
247
|
}>): readonly VitalSignObservationGraph[];
|
|
248
|
+
export type RepeatedVitalSignReading = Readonly<{
|
|
249
|
+
entryId: string;
|
|
250
|
+
measuredAt: string;
|
|
251
|
+
systolic: number;
|
|
252
|
+
diastolic: number;
|
|
253
|
+
note?: string;
|
|
254
|
+
}> | Readonly<{
|
|
255
|
+
entryId: string;
|
|
256
|
+
measuredAt: string;
|
|
257
|
+
value: number;
|
|
258
|
+
unit?: VitalSignQuantityUnit;
|
|
259
|
+
note?: string;
|
|
260
|
+
}>;
|
|
261
|
+
export type RepeatedVitalSignSession = Readonly<{
|
|
262
|
+
eventTiming: VitalSignEventTiming;
|
|
263
|
+
sourceGraphs: readonly VitalSignObservationGraph[];
|
|
264
|
+
averageGraph: VitalSignObservationGraph;
|
|
265
|
+
entries: readonly FlatClaimResourceEntry[];
|
|
266
|
+
}>;
|
|
267
|
+
/**
|
|
268
|
+
* Converts exactly three readings taken two minutes apart into registered raw
|
|
269
|
+
* evidence plus one preliminary arithmetic mean. Confirmation belongs to the
|
|
270
|
+
* daily document boundary; this helper never invents an attester.
|
|
271
|
+
*/
|
|
272
|
+
export declare function buildRepeatedVitalSignAverage(input: Readonly<{
|
|
273
|
+
entryId: string;
|
|
274
|
+
subjectReference: string;
|
|
275
|
+
deviceReference?: string;
|
|
276
|
+
kind: VitalSignKind;
|
|
277
|
+
eventTiming: VitalSignEventTiming;
|
|
278
|
+
readings: readonly RepeatedVitalSignReading[];
|
|
279
|
+
}>): RepeatedVitalSignSession;
|
|
280
|
+
export type VitalSignDailyDocument = Readonly<{
|
|
281
|
+
localDate: string;
|
|
282
|
+
timeZone: string;
|
|
283
|
+
status: 'preliminary' | 'final';
|
|
284
|
+
composition: FlatClaimResourceEntry;
|
|
285
|
+
observations: readonly VitalSignObservationGraph[];
|
|
286
|
+
entries: readonly FlatClaimResourceEntry[];
|
|
287
|
+
}>;
|
|
288
|
+
/** Builds one daily FHIR document graph; hashing, indexing and research remain service policy. */
|
|
289
|
+
export declare function buildVitalSignDailyDocument(input: Readonly<{
|
|
290
|
+
entryId: string;
|
|
291
|
+
identifier: string;
|
|
292
|
+
subjectReference: string;
|
|
293
|
+
authorReference: string;
|
|
294
|
+
localDate: string;
|
|
295
|
+
timeZone: string;
|
|
296
|
+
observationGraphs: readonly VitalSignObservationGraph[];
|
|
297
|
+
}>): VitalSignDailyDocument;
|
|
298
|
+
/** Confirms a preliminary daily document and finalizes its top-level Observations atomically in memory. */
|
|
299
|
+
export declare function finalizeVitalSignDailyDocument(draft: VitalSignDailyDocument, confirmation: Readonly<{
|
|
300
|
+
attesterReference: string;
|
|
301
|
+
confirmedAt: string;
|
|
302
|
+
}>): VitalSignDailyDocument;
|
|
229
303
|
/** Reusable neutral fixtures; consumers and tests must not duplicate wire examples. */
|
|
230
304
|
export declare const VITAL_SIGN_EXAMPLES: Readonly<{
|
|
231
305
|
subjectReference: "Patient/example-subject";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildFlatClaimResourceEntry } from './flat-claim-resource-graph.js';
|
|
2
|
+
import { CompositionAttesterModes, CompositionClaim } from './claims/composition-claims.js';
|
|
2
3
|
import { FhirCodeSystem, ObservationCategoryCodes, ObservationClaim, decodeObservationClaimList, } from './observation-claims.js';
|
|
3
4
|
export { ObservationCategoryCodes, ObservationClaim } from './observation-claims.js';
|
|
4
5
|
function defineCoding(system, code, display) {
|
|
@@ -20,8 +21,10 @@ export const VitalSignsUnits = Object.freeze({
|
|
|
20
21
|
BeatsPerMinute: defineCoding(FhirCodeSystem.Ucum, '/min', 'beats/minute'),
|
|
21
22
|
MillimeterOfMercury: defineCoding(FhirCodeSystem.Ucum, 'mm[Hg]', 'mmHg'),
|
|
22
23
|
Celsius: defineCoding(FhirCodeSystem.Ucum, 'Cel', 'degrees Celsius'),
|
|
24
|
+
Fahrenheit: defineCoding(FhirCodeSystem.Ucum, '[degF]', 'degrees Fahrenheit'),
|
|
23
25
|
Percent: defineCoding(FhirCodeSystem.Ucum, '%', 'percent'),
|
|
24
26
|
Kilogram: defineCoding(FhirCodeSystem.Ucum, 'kg', 'kilogram'),
|
|
27
|
+
Pound: defineCoding(FhirCodeSystem.Ucum, '[lb_av]', 'pound'),
|
|
25
28
|
});
|
|
26
29
|
/** Governed FHIR/LOINC tokens used by every channel that captures vital signs. */
|
|
27
30
|
export const VitalSignCode = Object.freeze({
|
|
@@ -38,10 +41,12 @@ export const VitalSignCode = Object.freeze({
|
|
|
38
41
|
export const VitalSignUnit = Object.freeze({
|
|
39
42
|
bloodPressure: VitalSignsUnits.MillimeterOfMercury.claim,
|
|
40
43
|
temperature: VitalSignsUnits.Celsius.claim,
|
|
44
|
+
temperatureFahrenheit: VitalSignsUnits.Fahrenheit.claim,
|
|
41
45
|
heartRate: VitalSignsUnits.BeatsPerMinute.claim,
|
|
42
46
|
oxygenSaturation: VitalSignsUnits.Percent.claim,
|
|
43
47
|
respiratoryRate: VitalSignsUnits.BeatsPerMinute.claim,
|
|
44
48
|
bodyWeight: VitalSignsUnits.Kilogram.claim,
|
|
49
|
+
bodyWeightPound: VitalSignsUnits.Pound.claim,
|
|
45
50
|
});
|
|
46
51
|
/**
|
|
47
52
|
* Builds one timestamped Observation. The device is retained as source; this
|
|
@@ -58,6 +63,8 @@ export function buildVitalSignObservation(input) {
|
|
|
58
63
|
};
|
|
59
64
|
if (input.deviceReference)
|
|
60
65
|
claims[ObservationClaim.Device] = required(input.deviceReference, 'vital_sign_device_required');
|
|
66
|
+
if (input.eventTiming)
|
|
67
|
+
claims[ObservationClaim.DateWhen] = normalizeEventTiming(input.eventTiming);
|
|
61
68
|
if (input.note?.trim())
|
|
62
69
|
claims[ObservationClaim.Note] = input.note.trim();
|
|
63
70
|
if (input.userSelected !== undefined)
|
|
@@ -79,7 +86,7 @@ export function buildVitalSignObservation(input) {
|
|
|
79
86
|
else {
|
|
80
87
|
if (!validPositive(input.value))
|
|
81
88
|
throw new TypeError('vital_sign_value_invalid');
|
|
82
|
-
const scalar = scalarDefinition(input.kind);
|
|
89
|
+
const scalar = scalarDefinition(input.kind, input.unit);
|
|
83
90
|
claims[ObservationClaim.Code] = scalar.coding.claim;
|
|
84
91
|
if (scalar.coding.display)
|
|
85
92
|
claims[ObservationClaim.CodeDisplay] = scalar.coding.display;
|
|
@@ -213,6 +220,123 @@ export function normalizeVitalSignDeviceBatch(input) {
|
|
|
213
220
|
});
|
|
214
221
|
}));
|
|
215
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Converts exactly three readings taken two minutes apart into registered raw
|
|
225
|
+
* evidence plus one preliminary arithmetic mean. Confirmation belongs to the
|
|
226
|
+
* daily document boundary; this helper never invents an attester.
|
|
227
|
+
*/
|
|
228
|
+
export function buildRepeatedVitalSignAverage(input) {
|
|
229
|
+
if (input.readings.length !== 3)
|
|
230
|
+
throw new TypeError('vital_sign_repeated_reading_count_invalid');
|
|
231
|
+
const instants = input.readings.map(reading => normalizeDateTime(reading.measuredAt));
|
|
232
|
+
for (let index = 1; index < instants.length; index += 1) {
|
|
233
|
+
if (Date.parse(instants[index]) - Date.parse(instants[index - 1]) !== 120_000) {
|
|
234
|
+
throw new TypeError('vital_sign_repeated_reading_cadence_invalid');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const eventTiming = normalizeEventTiming(input.eventTiming);
|
|
238
|
+
const sourceGraphs = Object.freeze(input.readings.map(reading => buildVitalSignObservation({
|
|
239
|
+
...reading,
|
|
240
|
+
subjectReference: input.subjectReference,
|
|
241
|
+
...(input.deviceReference ? { deviceReference: input.deviceReference } : {}),
|
|
242
|
+
kind: input.kind,
|
|
243
|
+
status: 'registered',
|
|
244
|
+
eventTiming,
|
|
245
|
+
})));
|
|
246
|
+
const common = {
|
|
247
|
+
entryId: input.entryId,
|
|
248
|
+
subjectReference: input.subjectReference,
|
|
249
|
+
...(input.deviceReference ? { deviceReference: input.deviceReference } : {}),
|
|
250
|
+
kind: input.kind,
|
|
251
|
+
measuredAt: instants[1],
|
|
252
|
+
status: 'preliminary',
|
|
253
|
+
eventTiming,
|
|
254
|
+
...(input.kind === 'blood-pressure' ? {} : { unit: repeatedScalarUnit(input.kind, input.readings) }),
|
|
255
|
+
};
|
|
256
|
+
const average = input.kind === 'blood-pressure'
|
|
257
|
+
? buildVitalSignObservation({
|
|
258
|
+
...common,
|
|
259
|
+
kind: 'blood-pressure',
|
|
260
|
+
systolic: mean(input.readings.map(reading => bloodPressureValue(reading, 'systolic'))),
|
|
261
|
+
diastolic: mean(input.readings.map(reading => bloodPressureValue(reading, 'diastolic'))),
|
|
262
|
+
})
|
|
263
|
+
: buildVitalSignObservation({
|
|
264
|
+
...common,
|
|
265
|
+
kind: input.kind,
|
|
266
|
+
value: mean(input.readings.map(scalarReadingValue)),
|
|
267
|
+
});
|
|
268
|
+
const derivedFrom = sourceGraphs.map(graph => graph.primary.reference).join(',');
|
|
269
|
+
const averageGraph = replacePrimaryClaims(average, {
|
|
270
|
+
...average.primary.claims,
|
|
271
|
+
[ObservationClaim.DerivedFrom]: derivedFrom,
|
|
272
|
+
});
|
|
273
|
+
return Object.freeze({
|
|
274
|
+
eventTiming,
|
|
275
|
+
sourceGraphs,
|
|
276
|
+
averageGraph,
|
|
277
|
+
entries: Object.freeze([...sourceGraphs.flatMap(graph => graph.entries), ...averageGraph.entries]),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Builds one daily FHIR document graph; hashing, indexing and research remain service policy. */
|
|
281
|
+
export function buildVitalSignDailyDocument(input) {
|
|
282
|
+
const localDate = normalizeLocalDate(input.localDate);
|
|
283
|
+
const timeZone = normalizeTimeZone(input.timeZone);
|
|
284
|
+
if (!input.observationGraphs.length)
|
|
285
|
+
throw new TypeError('vital_sign_daily_observations_required');
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
for (const graph of input.observationGraphs) {
|
|
288
|
+
if (graph.primary.claims[ObservationClaim.Subject] !== input.subjectReference)
|
|
289
|
+
throw new TypeError('vital_sign_daily_subject_mismatch');
|
|
290
|
+
const measuredAt = required(graph.primary.claims[ObservationClaim.EffectiveDateTime] ?? '', 'vital_sign_measured_at_required');
|
|
291
|
+
if (localDateAt(measuredAt, timeZone) !== localDate)
|
|
292
|
+
throw new TypeError('vital_sign_daily_local_date_mismatch');
|
|
293
|
+
for (const entry of graph.entries) {
|
|
294
|
+
if (seen.has(entry.reference))
|
|
295
|
+
throw new TypeError('vital_sign_daily_entry_duplicate');
|
|
296
|
+
seen.add(entry.reference);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const observationReferences = input.observationGraphs.map(graph => graph.primary.reference);
|
|
300
|
+
const composition = buildFlatClaimResourceEntry({
|
|
301
|
+
entryId: input.entryId,
|
|
302
|
+
resourceType: 'Composition',
|
|
303
|
+
claims: {
|
|
304
|
+
[CompositionClaim.Identifier]: required(input.identifier, 'vital_sign_daily_identifier_required'),
|
|
305
|
+
[CompositionClaim.Status]: 'preliminary',
|
|
306
|
+
[CompositionClaim.Subject]: required(input.subjectReference, 'vital_sign_subject_required'),
|
|
307
|
+
[CompositionClaim.Author]: required(input.authorReference, 'vital_sign_daily_author_required'),
|
|
308
|
+
[CompositionClaim.Date]: latestEffectiveDateTime(input.observationGraphs),
|
|
309
|
+
[CompositionClaim.Type]: `${FhirCodeSystem.Loinc}|8716-3`,
|
|
310
|
+
[CompositionClaim.Title]: `Daily vital signs ${localDate}`,
|
|
311
|
+
[CompositionClaim.Entry]: observationReferences.join(','),
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
return freezeDailyDocument(localDate, timeZone, 'preliminary', composition, input.observationGraphs);
|
|
315
|
+
}
|
|
316
|
+
/** Confirms a preliminary daily document and finalizes its top-level Observations atomically in memory. */
|
|
317
|
+
export function finalizeVitalSignDailyDocument(draft, confirmation) {
|
|
318
|
+
if (draft.status !== 'preliminary' || draft.composition.claims[CompositionClaim.Status] !== 'preliminary') {
|
|
319
|
+
throw new TypeError('vital_sign_daily_preliminary_required');
|
|
320
|
+
}
|
|
321
|
+
const confirmedAt = normalizeDateTime(confirmation.confirmedAt);
|
|
322
|
+
const attesterReference = required(confirmation.attesterReference, 'vital_sign_daily_attester_required');
|
|
323
|
+
const observations = Object.freeze(draft.observations.map(graph => replacePrimaryClaims(graph, {
|
|
324
|
+
...graph.primary.claims,
|
|
325
|
+
[ObservationClaim.Status]: 'final',
|
|
326
|
+
})));
|
|
327
|
+
const composition = buildFlatClaimResourceEntry({
|
|
328
|
+
entryId: draft.composition.entryId,
|
|
329
|
+
resourceType: 'Composition',
|
|
330
|
+
claims: {
|
|
331
|
+
...draft.composition.claims,
|
|
332
|
+
[CompositionClaim.Status]: 'final',
|
|
333
|
+
[CompositionClaim.Attester]: attesterReference,
|
|
334
|
+
[CompositionClaim.AttesterMode]: CompositionAttesterModes.Professional,
|
|
335
|
+
[CompositionClaim.AttesterTime]: confirmedAt,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
return freezeDailyDocument(draft.localDate, draft.timeZone, 'final', composition, observations);
|
|
339
|
+
}
|
|
216
340
|
function buildBloodPressureComponent(input, role, value, coding) {
|
|
217
341
|
const entryId = `${required(input.entryId, 'vital_sign_entry_id_required')}-${role}`;
|
|
218
342
|
const claims = {
|
|
@@ -228,6 +352,8 @@ function buildBloodPressureComponent(input, role, value, coding) {
|
|
|
228
352
|
claims[ObservationClaim.CodeDisplay] = coding.display;
|
|
229
353
|
if (input.deviceReference)
|
|
230
354
|
claims[ObservationClaim.Device] = required(input.deviceReference, 'vital_sign_device_required');
|
|
355
|
+
if (input.eventTiming)
|
|
356
|
+
claims[ObservationClaim.DateWhen] = normalizeEventTiming(input.eventTiming);
|
|
231
357
|
return buildFlatClaimResourceEntry({ entryId, resourceType: 'Observation', claims });
|
|
232
358
|
}
|
|
233
359
|
function freezeObservationGraph(primary, components) {
|
|
@@ -248,14 +374,20 @@ function componentQuantityFromEntry(entry) {
|
|
|
248
374
|
const display = entry.claims[ObservationClaim.CodeDisplay];
|
|
249
375
|
return Object.freeze({ code, ...(display ? { display } : {}), value, unit });
|
|
250
376
|
}
|
|
251
|
-
function scalarDefinition(kind) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
377
|
+
function scalarDefinition(kind, requestedUnit) {
|
|
378
|
+
const definition = (() => {
|
|
379
|
+
switch (kind) {
|
|
380
|
+
case 'temperature': return { coding: VitalSignsCodes.BodyTemperature, units: [VitalSignUnit.temperature, VitalSignUnit.temperatureFahrenheit] };
|
|
381
|
+
case 'heart-rate': return { coding: VitalSignsCodes.HeartRate, units: [VitalSignUnit.heartRate] };
|
|
382
|
+
case 'oxygen-saturation': return { coding: VitalSignsCodes.OxygenSaturation, units: [VitalSignUnit.oxygenSaturation] };
|
|
383
|
+
case 'respiratory-rate': return { coding: VitalSignsCodes.RespiratoryRate, units: [VitalSignUnit.respiratoryRate] };
|
|
384
|
+
case 'body-weight': return { coding: VitalSignsCodes.BodyWeight, units: [VitalSignUnit.bodyWeight, VitalSignUnit.bodyWeightPound] };
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
const unit = requestedUnit ?? definition.units[0];
|
|
388
|
+
if (!definition.units.includes(unit))
|
|
389
|
+
throw new TypeError('vital_sign_unit_invalid');
|
|
390
|
+
return { coding: definition.coding, unit };
|
|
259
391
|
}
|
|
260
392
|
function normalizeDateTime(value) {
|
|
261
393
|
const date = new Date(value);
|
|
@@ -263,6 +395,90 @@ function normalizeDateTime(value) {
|
|
|
263
395
|
throw new TypeError('vital_sign_measured_at_invalid');
|
|
264
396
|
return date.toISOString();
|
|
265
397
|
}
|
|
398
|
+
function normalizeEventTiming(value) {
|
|
399
|
+
if (value === 'MORN' || value === 'AFT' || value === 'EVE' || value === 'NIGHT')
|
|
400
|
+
return value;
|
|
401
|
+
throw new TypeError('vital_sign_event_timing_invalid');
|
|
402
|
+
}
|
|
403
|
+
function mean(values) {
|
|
404
|
+
if (values.length === 0 || values.some(value => !validPositive(value)))
|
|
405
|
+
throw new TypeError('vital_sign_value_invalid');
|
|
406
|
+
return Math.round((values.reduce((total, value) => total + value, 0) / values.length) * 10) / 10;
|
|
407
|
+
}
|
|
408
|
+
function bloodPressureValue(reading, key) {
|
|
409
|
+
if (!('systolic' in reading) || !('diastolic' in reading))
|
|
410
|
+
throw new TypeError('blood_pressure_values_invalid');
|
|
411
|
+
const value = key === 'systolic' ? reading.systolic : reading.diastolic;
|
|
412
|
+
if (typeof value !== 'number')
|
|
413
|
+
throw new TypeError('blood_pressure_values_invalid');
|
|
414
|
+
return value;
|
|
415
|
+
}
|
|
416
|
+
function scalarReadingValue(reading) {
|
|
417
|
+
if (!('value' in reading) || typeof reading.value !== 'number')
|
|
418
|
+
throw new TypeError('vital_sign_value_invalid');
|
|
419
|
+
return reading.value;
|
|
420
|
+
}
|
|
421
|
+
function repeatedScalarUnit(kind, readings) {
|
|
422
|
+
const units = readings.map(reading => 'unit' in reading ? reading.unit : undefined);
|
|
423
|
+
const selected = units.find((unit) => Boolean(unit));
|
|
424
|
+
if (units.some(unit => unit !== undefined && unit !== selected))
|
|
425
|
+
throw new TypeError('vital_sign_repeated_unit_mismatch');
|
|
426
|
+
return scalarDefinition(kind, selected).unit;
|
|
427
|
+
}
|
|
428
|
+
function replacePrimaryClaims(graph, claims) {
|
|
429
|
+
const primary = buildFlatClaimResourceEntry({
|
|
430
|
+
entryId: graph.primary.entryId,
|
|
431
|
+
resourceType: 'Observation',
|
|
432
|
+
claims,
|
|
433
|
+
});
|
|
434
|
+
return freezeObservationGraph(primary, graph.components);
|
|
435
|
+
}
|
|
436
|
+
function freezeDailyDocument(localDate, timeZone, status, composition, observations) {
|
|
437
|
+
const frozenObservations = Object.freeze([...observations]);
|
|
438
|
+
return Object.freeze({
|
|
439
|
+
localDate,
|
|
440
|
+
timeZone,
|
|
441
|
+
status,
|
|
442
|
+
composition,
|
|
443
|
+
observations: frozenObservations,
|
|
444
|
+
entries: Object.freeze([composition, ...frozenObservations.flatMap(graph => graph.entries)]),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
function normalizeLocalDate(value) {
|
|
448
|
+
const normalized = value.trim();
|
|
449
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized))
|
|
450
|
+
throw new TypeError('vital_sign_daily_local_date_invalid');
|
|
451
|
+
const parsed = new Date(`${normalized}T00:00:00.000Z`);
|
|
452
|
+
if (Number.isNaN(parsed.valueOf()) || parsed.toISOString().slice(0, 10) !== normalized) {
|
|
453
|
+
throw new TypeError('vital_sign_daily_local_date_invalid');
|
|
454
|
+
}
|
|
455
|
+
return normalized;
|
|
456
|
+
}
|
|
457
|
+
function normalizeTimeZone(value) {
|
|
458
|
+
const normalized = value.trim();
|
|
459
|
+
if (!normalized)
|
|
460
|
+
throw new TypeError('vital_sign_daily_time_zone_invalid');
|
|
461
|
+
try {
|
|
462
|
+
new Intl.DateTimeFormat('en', { timeZone: normalized }).format(0);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
throw new TypeError('vital_sign_daily_time_zone_invalid');
|
|
466
|
+
}
|
|
467
|
+
return normalized;
|
|
468
|
+
}
|
|
469
|
+
function localDateAt(dateTime, timeZone) {
|
|
470
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
471
|
+
timeZone,
|
|
472
|
+
year: 'numeric',
|
|
473
|
+
month: '2-digit',
|
|
474
|
+
day: '2-digit',
|
|
475
|
+
}).formatToParts(new Date(dateTime));
|
|
476
|
+
const value = Object.fromEntries(parts.map(part => [part.type, part.value]));
|
|
477
|
+
return `${value.year}-${value.month}-${value.day}`;
|
|
478
|
+
}
|
|
479
|
+
function latestEffectiveDateTime(graphs) {
|
|
480
|
+
return graphs.map(graph => required(graph.primary.claims[ObservationClaim.EffectiveDateTime] ?? '', 'vital_sign_measured_at_required')).sort().at(-1);
|
|
481
|
+
}
|
|
266
482
|
function validPositive(value) {
|
|
267
483
|
return Number.isFinite(value) && value > 0;
|
|
268
484
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Daily vital-sign document lifecycle
|
|
2
|
+
|
|
3
|
+
This package owns the product-neutral FHIR data contract used by web portals,
|
|
4
|
+
telephone assistants, chatbots and device importers. It does not decide who
|
|
5
|
+
may attest, write an index, anchor a hash on a ledger or contribute data to a
|
|
6
|
+
research study.
|
|
7
|
+
|
|
8
|
+
## Repeated readings
|
|
9
|
+
|
|
10
|
+
`buildRepeatedVitalSignAverage(...)` requires exactly three readings separated
|
|
11
|
+
by two minutes. It returns:
|
|
12
|
+
|
|
13
|
+
- three raw `Observation` graphs with `status=registered`;
|
|
14
|
+
- one arithmetic-mean `Observation` with `status=preliminary`;
|
|
15
|
+
- `Observation.date-when=MORN|AFT|EVE|NIGHT` on raw and mean observations;
|
|
16
|
+
- `Observation.derived-from` references from the mean to all raw observations.
|
|
17
|
+
|
|
18
|
+
Blood pressure remains one native Observation with systolic and diastolic
|
|
19
|
+
components. Its flat representation keeps the established primary
|
|
20
|
+
`Observation.has-member` graph. No `bp-*`, `component-*`, containment or split
|
|
21
|
+
coding claims are written.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const morningBloodPressure = buildRepeatedVitalSignAverage({
|
|
25
|
+
entryId: averageObservationId,
|
|
26
|
+
subjectReference,
|
|
27
|
+
deviceReference,
|
|
28
|
+
kind: 'blood-pressure',
|
|
29
|
+
eventTiming: 'MORN',
|
|
30
|
+
readings: threeTimestampedReadings,
|
|
31
|
+
})
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Temperature accepts governed UCUM Celsius or Fahrenheit units. Body weight
|
|
35
|
+
accepts governed UCUM kilograms or avoirdupois pounds. A UI selects one of the
|
|
36
|
+
exported `VitalSignUnit` constants; it never assembles a UCUM token itself.
|
|
37
|
+
|
|
38
|
+
## One document per local day
|
|
39
|
+
|
|
40
|
+
`buildVitalSignDailyDocument(...)` validates every Observation against an
|
|
41
|
+
explicit IANA time zone and local calendar date. It returns one preliminary
|
|
42
|
+
`Composition` followed by every Observation graph entry. The Composition
|
|
43
|
+
references only the top-level Observations through `Composition.entry`; reduced
|
|
44
|
+
blood-pressure members remain reachable through `Observation.has-member`.
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const dailyDraft = buildVitalSignDailyDocument({
|
|
48
|
+
entryId: dailyCompositionId,
|
|
49
|
+
identifier: dailyDocumentIdentifier,
|
|
50
|
+
subjectReference,
|
|
51
|
+
authorReference,
|
|
52
|
+
localDate,
|
|
53
|
+
timeZone,
|
|
54
|
+
observationGraphs: [
|
|
55
|
+
...morningBloodPressure.sourceGraphs,
|
|
56
|
+
morningBloodPressure.averageGraph,
|
|
57
|
+
],
|
|
58
|
+
})
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The draft is readable as preliminary clinical work. It is not final evidence
|
|
62
|
+
and this package does not make it eligible for an individual index, Digital
|
|
63
|
+
Twin or research projection.
|
|
64
|
+
|
|
65
|
+
`finalizeVitalSignDailyDocument(...)` requires a preliminary document, records
|
|
66
|
+
the professional attester and confirmation time, and returns a new immutable
|
|
67
|
+
graph with the Composition and all top-level Observations set to `final`.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const finalDailyDocument = finalizeVitalSignDailyDocument(dailyDraft, {
|
|
71
|
+
attesterReference,
|
|
72
|
+
confirmedAt,
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The owning service may now serialize and hash this one daily document graph.
|
|
77
|
+
Index, ledger and research writes remain separate policy-controlled operations;
|
|
78
|
+
failure in one must not silently claim success in another.
|
|
79
|
+
|