fhir-data-utils-ts 0.3.6 → 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.
Files changed (35) hide show
  1. package/.codex/skills/govern-fhir-data-utilities/SKILL.md +12 -0
  2. package/.codex/skills/govern-fhir-observation-components/SKILL.md +11 -0
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +18 -0
  5. package/dist/care-emergency-alert.d.ts +70 -0
  6. package/dist/care-emergency-alert.js +135 -0
  7. package/dist/claims/communication-request-claims.d.ts +21 -0
  8. package/dist/claims/communication-request-claims.js +27 -0
  9. package/dist/claims/composition-claims.d.ts +1 -0
  10. package/dist/claims/composition-claims.js +1 -0
  11. package/dist/claims/encounter-claims.d.ts +1 -0
  12. package/dist/claims/encounter-claims.js +2 -0
  13. package/dist/claims/index.d.ts +2 -0
  14. package/dist/claims/index.js +2 -0
  15. package/dist/claims/service-request-claims.d.ts +24 -0
  16. package/dist/claims/service-request-claims.js +33 -0
  17. package/dist/constants/fhir-resource-types.d.ts +3 -0
  18. package/dist/constants/fhir-resource-types.js +1 -0
  19. package/dist/converters/convert-communication-request.d.ts +5 -0
  20. package/dist/converters/convert-communication-request.js +40 -0
  21. package/dist/converters/convert-composition.js +12 -2
  22. package/dist/converters/convert-encounter.js +2 -0
  23. package/dist/converters/convert-service-request.d.ts +5 -0
  24. package/dist/converters/convert-service-request.js +47 -0
  25. package/dist/converters/index.d.ts +2 -0
  26. package/dist/converters/index.js +2 -0
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +1 -0
  29. package/dist/observation-claims.d.ts +3 -0
  30. package/dist/observation-claims.js +3 -0
  31. package/dist/vital-sign-observations.d.ts +75 -1
  32. package/dist/vital-sign-observations.js +225 -9
  33. package/docs/CARE_EMERGENCY_ALERT_CONTRACTS.md +50 -0
  34. package/docs/VITAL_SIGN_DAILY_DOCUMENT.md +79 -0
  35. package/package.json +5 -1
@@ -0,0 +1,47 @@
1
+ import { ServiceRequestClaim } from '../claims/service-request-claims.js';
2
+ import { codingFromValue, codingListFromCsv, codingListToCsv, codingToValue, referenceListToCsv, referenceToValue } from './convert-shared.js';
3
+ /** Projects canonical ServiceRequest flat claims to the FHIR R4 wire shape. */
4
+ export function serviceRequestFlatToFhirR4(claims) {
5
+ const codeCoding = codingFromValue(claims[ServiceRequestClaim.Code])?.[0];
6
+ return {
7
+ resourceType: 'ServiceRequest',
8
+ identifier: claims[ServiceRequestClaim.Identifier] ? [{ value: claims[ServiceRequestClaim.Identifier] }] : undefined,
9
+ status: claims[ServiceRequestClaim.Status], intent: claims[ServiceRequestClaim.Intent], priority: claims[ServiceRequestClaim.Priority],
10
+ category: claims[ServiceRequestClaim.Category] ? [{ coding: codingFromValue(claims[ServiceRequestClaim.Category]) }] : undefined,
11
+ code: codeCoding || claims[ServiceRequestClaim.CodeText] ? { coding: codeCoding ? [codeCoding] : undefined, text: claims[ServiceRequestClaim.CodeText] } : undefined,
12
+ subject: claims[ServiceRequestClaim.Subject] ? { reference: claims[ServiceRequestClaim.Subject] } : undefined,
13
+ encounter: claims[ServiceRequestClaim.Encounter] ? { reference: claims[ServiceRequestClaim.Encounter] } : undefined,
14
+ requester: claims[ServiceRequestClaim.Requester] ? { reference: claims[ServiceRequestClaim.Requester] } : undefined,
15
+ performer: referenceCsv(claims[ServiceRequestClaim.Performer]), authoredOn: claims[ServiceRequestClaim.AuthoredOn],
16
+ occurrenceDateTime: claims[ServiceRequestClaim.Occurrence],
17
+ reasonCode: claims[ServiceRequestClaim.ReasonCode] ? [{ coding: codingListFromCsv(claims[ServiceRequestClaim.ReasonCode]) }] : undefined,
18
+ reasonReference: referenceCsv(claims[ServiceRequestClaim.ReasonReference]),
19
+ supportingInfo: referenceCsv(claims[ServiceRequestClaim.SupportingInfo]), specimen: referenceCsv(claims[ServiceRequestClaim.Specimen]),
20
+ bodySite: claims[ServiceRequestClaim.BodySite] ? [{ coding: codingListFromCsv(claims[ServiceRequestClaim.BodySite]) }] : undefined,
21
+ };
22
+ }
23
+ /** Reads a FHIR R4 ServiceRequest into canonical compact-token flat claims. */
24
+ export function serviceRequestFhirR4ToFlat(resource) {
25
+ const code = resource.code;
26
+ return {
27
+ [ServiceRequestClaim.Identifier]: resource.identifier?.[0]?.value,
28
+ [ServiceRequestClaim.Status]: resource.status, [ServiceRequestClaim.Intent]: resource.intent,
29
+ [ServiceRequestClaim.Priority]: resource.priority,
30
+ [ServiceRequestClaim.Category]: codingToValue(resource.category?.[0]?.coding?.[0]),
31
+ [ServiceRequestClaim.Code]: codingToValue(code?.coding?.[0]), [ServiceRequestClaim.CodeText]: code?.text,
32
+ [ServiceRequestClaim.Subject]: referenceToValue(resource.subject),
33
+ [ServiceRequestClaim.Encounter]: referenceToValue(resource.encounter),
34
+ [ServiceRequestClaim.Requester]: referenceToValue(resource.requester),
35
+ [ServiceRequestClaim.Performer]: referenceListToCsv(resource.performer),
36
+ [ServiceRequestClaim.AuthoredOn]: resource.authoredOn, [ServiceRequestClaim.Occurrence]: resource.occurrenceDateTime,
37
+ [ServiceRequestClaim.ReasonCode]: codingListToCsv(resource.reasonCode?.flatMap(value => value.coding ?? [])),
38
+ [ServiceRequestClaim.ReasonReference]: referenceListToCsv(resource.reasonReference),
39
+ [ServiceRequestClaim.SupportingInfo]: referenceListToCsv(resource.supportingInfo),
40
+ [ServiceRequestClaim.Specimen]: referenceListToCsv(resource.specimen),
41
+ [ServiceRequestClaim.BodySite]: codingListToCsv(resource.bodySite?.flatMap(value => value.coding ?? [])),
42
+ };
43
+ }
44
+ function referenceCsv(value) {
45
+ const references = value?.split(',').map(reference => reference.trim()).filter(Boolean) ?? [];
46
+ return references.length ? references.map(reference => ({ reference })) : undefined;
47
+ }
@@ -4,6 +4,7 @@ export * from './convert-appointment-response.js';
4
4
  export * from './convert-appointment.js';
5
5
  export * from './convert-care-plan.js';
6
6
  export * from './convert-clinical-impression.js';
7
+ export * from './convert-communication-request.js';
7
8
  export * from './convert-composition.js';
8
9
  export * from './convert-consent.js';
9
10
  export * from './convert-condition.js';
@@ -23,3 +24,4 @@ export * from './convert-organization.js';
23
24
  export * from './convert-practitioner-role.js';
24
25
  export * from './convert-procedure.js';
25
26
  export * from './convert-related-person.js';
27
+ export * from './convert-service-request.js';
@@ -4,6 +4,7 @@ export * from './convert-appointment-response.js';
4
4
  export * from './convert-appointment.js';
5
5
  export * from './convert-care-plan.js';
6
6
  export * from './convert-clinical-impression.js';
7
+ export * from './convert-communication-request.js';
7
8
  export * from './convert-composition.js';
8
9
  export * from './convert-consent.js';
9
10
  export * from './convert-condition.js';
@@ -23,3 +24,4 @@ export * from './convert-organization.js';
23
24
  export * from './convert-practitioner-role.js';
24
25
  export * from './convert-procedure.js';
25
26
  export * from './convert-related-person.js';
27
+ export * from './convert-service-request.js';
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export * from './fhir-bundle.js';
14
14
  export * from './terminology-models/index.js';
15
15
  export * from './constants/index.js';
16
16
  export * from './clinical-terminology.js';
17
+ export * from './care-emergency-alert.js';
17
18
  export * from './immunization-flat-claims.js';
18
19
  export * from './immunization-reminder-plan.js';
19
20
  export * from './builders/index.js';
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ export * from './fhir-bundle.js';
14
14
  export * from './terminology-models/index.js';
15
15
  export * from './constants/index.js';
16
16
  export * from './clinical-terminology.js';
17
+ export * from './care-emergency-alert.js';
17
18
  export * from './immunization-flat-claims.js';
18
19
  export * from './immunization-reminder-plan.js';
19
20
  export * from './builders/index.js';
@@ -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,50 @@
1
+ # Care, emergency and population-alert FHIR contracts
2
+
3
+ This package owns only the product-neutral FHIR representation used by the
4
+ three SOSChain journeys that are first proven in VetChain and PetChain.
5
+ Authorization, controller discovery, encryption, transport, storage and UI
6
+ policy remain in their owning product or service layers.
7
+
8
+ ## Hospital or clinic care journey
9
+
10
+ An `Appointment` plans care. Marking it fulfilled can originate one parent
11
+ `Encounter`; it must not erase the `Encounter.appointment` reference. Admission,
12
+ imaging, intensive care and discharge are independent child `Encounter`
13
+ resources linked through `Encounter.partOf`, each with its actual `Location`.
14
+ This graph supports a bounded location timeline. It does not itself grant a
15
+ relative permission to see locations or clinical records.
16
+
17
+ A voice/video call or encrypted missed-call message may occur before a report
18
+ is published. Transport events are not clinical attestation and must not
19
+ silently change an Encounter, a daily draft or the individual's index.
20
+
21
+ ## Resilient emergency request
22
+
23
+ An emergency request is a standard claims-first `ServiceRequest`. Its service
24
+ and category are compact `system|code` tokens; writers never emit split
25
+ `code-system`, `code-value` or stored `code-display` claims. A local phrase may
26
+ remain in `ServiceRequest.code-text`; a trusted display is resolved from the
27
+ code at the terminology boundary.
28
+
29
+ Acknowledgement and triage use a separate durable `Task` whose `focus`
30
+ references the request. Requester, subject, intended performer and task owner
31
+ are explicit inputs. None of those references is inferred as authorization.
32
+
33
+ ## Donation and public-risk campaign
34
+
35
+ The campaign-level `CommunicationRequest.subject` is a frozen, enumerated
36
+ `Group` snapshot. Its `reasonReference` points to the definitional cohort used
37
+ to create that snapshot. Per-recipient eligibility, current Consent,
38
+ subscription/channel status, exclusions and delivery audit must be rechecked
39
+ outside this neutral package immediately before creating each individual
40
+ `Communication`.
41
+
42
+ The snapshot and request are evidence of evaluation and intent, not permission
43
+ to disclose group membership or send a message.
44
+
45
+ ## Public API
46
+
47
+ Use `fhir-data-utils-ts/care-emergency-alert` for graph builders and the package
48
+ root or `fhir-data-utils-ts/converters` for R4 projections. Persistence remains
49
+ flat claims. Native FHIR is materialized only at an explicit import/export
50
+ boundary.