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.
@@ -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,4 +1,5 @@
1
1
  export declare const CompositionClaim: {
2
+ readonly Status: "Composition.status";
2
3
  readonly Subject: "Composition.subject";
3
4
  readonly Section: "Composition.section";
4
5
  readonly Author: "Composition.author";
@@ -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
- : undefined,
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
- switch (kind) {
253
- case 'temperature': return { coding: VitalSignsCodes.BodyTemperature, unit: VitalSignUnit.temperature };
254
- case 'heart-rate': return { coding: VitalSignsCodes.HeartRate, unit: VitalSignUnit.heartRate };
255
- case 'oxygen-saturation': return { coding: VitalSignsCodes.OxygenSaturation, unit: VitalSignUnit.oxygenSaturation };
256
- case 'respiratory-rate': return { coding: VitalSignsCodes.RespiratoryRate, unit: VitalSignUnit.respiratoryRate };
257
- case 'body-weight': return { coding: VitalSignsCodes.BodyWeight, unit: VitalSignUnit.bodyWeight };
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
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fhir-data-utils-ts",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "FHIR-version-neutral flat-claim and SearchParameter data utilities",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",