fhir-data-utils-ts 0.3.7 → 0.6.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 (79) hide show
  1. package/.codex/skills/govern-fhir-data-utilities/SKILL.md +35 -0
  2. package/.codex/skills/govern-fhir-observation-components/SKILL.md +11 -0
  3. package/CHANGELOG.md +63 -0
  4. package/README.md +44 -0
  5. package/dist/appointment-communication-test-data.d.ts +63 -0
  6. package/dist/appointment-communication-test-data.js +77 -0
  7. package/dist/builders/department-service-graph.d.ts +32 -0
  8. package/dist/builders/department-service-graph.js +102 -0
  9. package/dist/builders/device-compliance-graph.d.ts +51 -0
  10. package/dist/builders/device-compliance-graph.js +115 -0
  11. package/dist/builders/index.d.ts +3 -0
  12. package/dist/builders/index.js +3 -0
  13. package/dist/builders/service-schedule-graph.d.ts +58 -0
  14. package/dist/builders/service-schedule-graph.js +149 -0
  15. package/dist/claims/appointment-claims.d.ts +6 -0
  16. package/dist/claims/appointment-claims.js +6 -0
  17. package/dist/claims/composition-claims.d.ts +1 -0
  18. package/dist/claims/composition-claims.js +1 -0
  19. package/dist/claims/device-metric-claims.d.ts +16 -0
  20. package/dist/claims/device-metric-claims.js +25 -0
  21. package/dist/claims/healthcare-service-claims.d.ts +32 -0
  22. package/dist/claims/healthcare-service-claims.js +54 -0
  23. package/dist/claims/index.d.ts +4 -0
  24. package/dist/claims/index.js +4 -0
  25. package/dist/claims/location-claims.d.ts +3 -0
  26. package/dist/claims/location-claims.js +6 -0
  27. package/dist/claims/organization-claims.d.ts +3 -1
  28. package/dist/claims/organization-claims.js +4 -1
  29. package/dist/claims/procedure-claims.d.ts +2 -0
  30. package/dist/claims/procedure-claims.js +4 -0
  31. package/dist/claims/regulated-authorization-claims.d.ts +17 -0
  32. package/dist/claims/regulated-authorization-claims.js +27 -0
  33. package/dist/claims/schedule-slot-claims.d.ts +45 -0
  34. package/dist/claims/schedule-slot-claims.js +70 -0
  35. package/dist/constants/fhir-resource-types.d.ts +9 -0
  36. package/dist/constants/fhir-resource-types.js +3 -0
  37. package/dist/converters/convert-appointment-communication.d.ts +14 -0
  38. package/dist/converters/convert-appointment-communication.js +97 -0
  39. package/dist/converters/convert-appointment.js +16 -7
  40. package/dist/converters/convert-composition.js +12 -2
  41. package/dist/converters/convert-device-metric.d.ts +3 -0
  42. package/dist/converters/convert-device-metric.js +37 -0
  43. package/dist/converters/convert-document-reference.js +5 -0
  44. package/dist/converters/convert-healthcare-service.d.ts +7 -0
  45. package/dist/converters/convert-healthcare-service.js +60 -0
  46. package/dist/converters/convert-location.d.ts +4 -0
  47. package/dist/converters/convert-location.js +41 -3
  48. package/dist/converters/convert-organization.d.ts +2 -0
  49. package/dist/converters/convert-organization.js +20 -3
  50. package/dist/converters/convert-procedure.d.ts +2 -0
  51. package/dist/converters/convert-procedure.js +21 -0
  52. package/dist/converters/convert-regulated-authorization.d.ts +3 -0
  53. package/dist/converters/convert-regulated-authorization.js +34 -0
  54. package/dist/converters/convert-schedule-slot.d.ts +9 -0
  55. package/dist/converters/convert-schedule-slot.js +96 -0
  56. package/dist/converters/convert-shared.d.ts +10 -0
  57. package/dist/converters/convert-shared.js +19 -0
  58. package/dist/converters/index.d.ts +6 -0
  59. package/dist/converters/index.js +6 -0
  60. package/dist/converters/materialize-claims-first-resource.d.ts +16 -0
  61. package/dist/converters/materialize-claims-first-resource.js +61 -0
  62. package/dist/fhir-bundle.d.ts +21 -0
  63. package/dist/fhir-bundle.js +74 -0
  64. package/dist/group-r5.d.ts +12 -0
  65. package/dist/group-r5.js +20 -0
  66. package/dist/healthcare-service-specialty-map.d.ts +32 -0
  67. package/dist/healthcare-service-specialty-map.js +151 -0
  68. package/dist/index.d.ts +2 -0
  69. package/dist/index.js +2 -0
  70. package/dist/observation-claims.d.ts +3 -0
  71. package/dist/observation-claims.js +3 -0
  72. package/dist/vital-sign-observations.d.ts +75 -1
  73. package/dist/vital-sign-observations.js +225 -9
  74. package/docs/CLINICAL_TYPED_ACCESSOR_BOUNDARY.md +44 -0
  75. package/docs/COMMUNICATION_BUNDLE_CONTRACT.md +61 -0
  76. package/docs/HEALTHCARE_SERVICE_DEPARTMENT_CATALOG.md +190 -0
  77. package/docs/UHC_FHIR_UTILS_MIGRATION.md +20 -1
  78. package/docs/VITAL_SIGN_DAILY_DOCUMENT.md +79 -0
  79. package/package.json +5 -1
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Candidate SNOMED CT C80 to LOINC specialty map supplied for review.
3
+ *
4
+ * This is not an official SNOMED or LOINC map. The declared relationship is
5
+ * preserved so consumers can automate only strict equivalence and present
6
+ * wider, related, inexact or unmatched rows for professional governance.
7
+ */
8
+ export const ClinicalSpecialtyMappingEquivalence = Object.freeze({
9
+ Equivalent: 'equivalent',
10
+ Wider: 'wider',
11
+ RelatedTo: 'relatedto',
12
+ Inexact: 'inexact',
13
+ Unmatched: 'unmatched',
14
+ });
15
+ const E = ClinicalSpecialtyMappingEquivalence.Equivalent;
16
+ const W = ClinicalSpecialtyMappingEquivalence.Wider;
17
+ const R = ClinicalSpecialtyMappingEquivalence.RelatedTo;
18
+ const I = ClinicalSpecialtyMappingEquivalence.Inexact;
19
+ const U = ClinicalSpecialtyMappingEquivalence.Unmatched;
20
+ const suppliedRows = Object.freeze([
21
+ ['408467006', 'Adult mental illness', 'LP172958-3', 'Mental health', W],
22
+ ['394577000', 'Anesthetics', 'LP172918-7', 'Anesthesiology', E],
23
+ ['394578005', 'Audiological medicine', 'LP172919-5', 'Audiology', I],
24
+ ['421661004', 'Blood banking and transfusion medicine', 'LP172920-3', 'Blood banking and transfusion medicine', E],
25
+ ['408462000', 'Burns care', 'LP268363-1', 'Burn management', I],
26
+ ['394579002', 'Cardiology', 'LA7179-0', 'Medicine.cardiology', E],
27
+ ['394804000', 'Clinical cytogenetics and molecular genetics', 'LP172929-4', 'Clinical genetics', W],
28
+ ['394580004', 'Clinical genetics', 'LP172929-4', 'Clinical genetics', E],
29
+ ['394803006', 'Clinical hematology', 'LP175685-9', 'Hematology', I],
30
+ ['408480009', 'Clinical immunology', 'LP345043-6', 'Immunology', W],
31
+ ['408454008', 'Clinical microbiology', 'LP208903-7', 'Pathology - Medical microbiology', I],
32
+ ['394809005', 'Clinical neuro-physiology', 'LP172931-0', 'Clinical neurophysiology', E],
33
+ ['394592004', 'Clinical oncology', 'LP172901-3', 'Oncology', I],
34
+ ['394600006', 'Clinical pharmacology', 'LP220238-2', 'Clinical pharmacology', E],
35
+ ['394601005', 'Clinical physiology', undefined, undefined, U],
36
+ ['394581000', 'Community medicine', 'LP204156-6', 'Community health care', I],
37
+ ['408478003', 'Critical care medicine', 'LP172933-6', 'Critical care medicine', E],
38
+ ['394812008', 'Dental medicine specialties', 'LP172934-4', 'Dentistry', W],
39
+ ['408444009', 'Dental-General dental practice', 'LP172934-4', 'Dentistry', W],
40
+ ['394582007', 'Dermatology', 'LP172935-1', 'Dermatology', E],
41
+ ['408475000', 'Diabetic medicine', 'LP172938-5', 'Diabetology', E],
42
+ ['410005002', 'Dive medicine', 'LP173035-9', 'Undersea and hyperbaric medicine', I],
43
+ ['394583002', 'Endocrinology', 'LP172941-9', 'Endocrinology', E],
44
+ ['419772000', 'Family practice', 'LP172943-5', 'Family medicine', E],
45
+ ['394584008', 'Gastroenterology', 'LP172945-0', 'Gastroenterology', E],
46
+ ['408443003', 'General medical practice', 'LP173010-2', 'Primary care', I],
47
+ ['394802001', 'General medicine', 'LP172946-8', 'General medicine', E],
48
+ ['394915009', 'General pathology', 'LP172984-9', 'Pathology', I],
49
+ ['394814009', 'General practice', 'LP173010-2', 'Primary care', I],
50
+ ['394808002', 'Genito-urinary medicine', 'LP173036-7', 'Urology', W],
51
+ ['394811001', 'Geriatric medicine', 'LP172947-6', 'Geriatric medicine', E],
52
+ ['408446006', 'Gynecological oncology', 'LP202989-2', 'Gynecologic oncology', E],
53
+ ['394586005', 'Gynecology', 'LP221284-5', 'Gynecology', E],
54
+ ['394916005', 'Hematopathology', 'LP172984-9', 'Pathology', W],
55
+ ['408472002', 'Hepatology', 'LP172950-0', 'Hepatology', E],
56
+ ['394597005', 'Histopathology', 'LP208907-8', 'Anatomic pathology', W],
57
+ ['394598000', 'Immunopathology', 'LP345043-6', 'Immunology', W],
58
+ ['394807007', 'Infectious diseases', 'LP172951-8', 'Infectious disease', E],
59
+ ['419192003', 'Internal medicine', 'LP172952-6', 'Internal medicine', E],
60
+ ['408468001', 'Learning disability', undefined, undefined, U],
61
+ ['394593009', 'Medical oncology', 'LP175686-7', 'Medical Oncology', E],
62
+ ['394813003', 'Medical ophthalmology', 'LP172974-0', 'Ophthalmology', W],
63
+ ['410001006', 'Military medicine', undefined, undefined, U],
64
+ ['394589003', 'Nephrology', 'LP172962-5', 'Nephrology', E],
65
+ ['394591006', 'Neurology', 'LP172964-1', 'Neurology', E],
66
+ ['394599008', 'Neuropathology', 'LP208907-8', 'Anatomic pathology', W],
67
+ ['394649004', 'Nuclear medicine', 'LP172968-2', 'Nuclear medicine', E],
68
+ ['408470005', 'Obstetrics', 'LP221283-7', 'Obstetrics', E],
69
+ ['394585009', 'Obstetrics and gynecology', 'LP172971-6', 'Obstetrics and gynecology', E],
70
+ ['394821009', 'Occupational medicine', 'LP172972-4', 'Occupational medicine', E],
71
+ ['422191005', 'Ophthalmic surgery', 'LP172974-0', 'Ophthalmology', W],
72
+ ['394594003', 'Ophthalmology', 'LP172974-0', 'Ophthalmology', E],
73
+ ['416304004', 'Osteopathic manipulative medicine', 'LP434767-2', 'Osteopathic medicine', I],
74
+ ['418960008', 'Otolaryngology', 'LP172980-7', 'Otolaryngology', E],
75
+ ['394882004', 'Pain management', 'LP172981-5', 'Pain medicine', I],
76
+ ['394806003', 'Palliative medicine', 'LP172982-3', 'Palliative care', I],
77
+ ['394588006', 'Pediatric (Child and adolescent) psychiatry', 'LP172922-9', 'Child and adolescent psychiatry', E],
78
+ ['408459003', 'Pediatric cardiology', 'LP172985-6', 'Pediatric cardiology', E],
79
+ ['394607009', 'Pediatric dentistry', 'LP172934-4', 'Dentistry', W],
80
+ ['419610006', 'Pediatric endocrinology', 'LP172989-8', 'Pediatric endocrinology', E],
81
+ ['418058008', 'Pediatric gastroenterology', 'LP172990-6', 'Pediatric gastroenterology', E],
82
+ ['420208008', 'Pediatric genetics', 'LP172956-7', 'Medical genetics', W],
83
+ ['418652005', 'Pediatric hematology', 'LP172991-4', 'Pediatric hematology-oncology', W],
84
+ ['418535003', 'Pediatric immunology', 'LP345043-6', 'Immunology', W],
85
+ ['418862001', 'Pediatric infectious diseases', 'LP172992-2', 'Pediatric infectious diseases', E],
86
+ ['419365004', 'Pediatric nephrology', 'LP172993-0', 'Pediatric nephrology', E],
87
+ ['418002000', 'Pediatric oncology', 'LP172991-4', 'Pediatric hematology-oncology', I],
88
+ ['419983000', 'Pediatric ophthalmology', 'LP172974-0', 'Ophthalmology', W],
89
+ ['419170002', 'Pediatric pulmonology', 'LP172995-5', 'Pediatric pulmonology', E],
90
+ ['419472004', 'Pediatric rheumatology', 'LP172998-9', 'Pediatric rheumatology', E],
91
+ ['394539006', 'Pediatric surgery', 'LP172999-7', 'Pediatric surgery', E],
92
+ ['420112009', 'Pediatric surgery-bone marrow transplantation', 'LP417851-5', 'Bone Marrow Transplant', I],
93
+ ['409968004', 'Preventive medicine', 'LP173009-4', 'Preventive medicine', E],
94
+ ['394587001', 'Psychiatry', 'LP173011-0', 'Psychiatry', E],
95
+ ['394913002', 'Psychotherapy', 'LP173012-8', 'Psychology', R],
96
+ ['408440000', 'Public health medicine', 'LP173014-4', 'Public health', I],
97
+ ['418112009', 'Pulmonary medicine', 'LP173015-1', 'Pulmonary disease', I],
98
+ ['419815003', 'Radiation oncology', 'LP173016-9', 'Radiation oncology', E],
99
+ ['394914008', 'Radiology', 'LP173018-5', 'Radiology', E],
100
+ ['408455009', 'Radiology-Interventional radiology', 'LP172896-5', 'Interventional radiology', E],
101
+ ['394602003', 'Rehabilitation', 'LP173004-5', 'Physical medicine and rehabilitation', I],
102
+ ['408447002', 'Respite care', 'LP173240-5', 'Respite', R],
103
+ ['394810000', 'Rheumatology', 'LP173023-5', 'Rheumatology', E],
104
+ ['408450004', 'Sleep studies', 'LP248728-0', 'Sleep medicine', I],
105
+ ['408476004', 'Surgery-Bone and marrow transplantation', 'LP417851-5', 'Bone Marrow Transplant', I],
106
+ ['408469009', 'Surgery-Breast surgery', 'LA7201-2', 'Surgery.breast', E],
107
+ ['408466002', 'Surgery-Cardiac surgery', 'LP207300-7', 'Cardiac surgery', E],
108
+ ['408471009', 'Surgery-Cardiothoracic transplantation', 'LP173033-4', 'Transplant surgery', W],
109
+ ['408464004', 'Surgery-Colorectal surgery', 'LP172932-8', 'Colon and rectal surgery', E],
110
+ ['408441001', 'Surgery-Dental-Endodontics', 'LP172934-4', 'Dentistry', W],
111
+ ['408465003', 'Surgery-Dental-Oral and maxillofacial surgery', 'LP172902-1', 'Oral and maxillofacial surgery', E],
112
+ ['394605001', 'Surgery-Dental-Oral surgery', 'LP172902-1', 'Oral and maxillofacial surgery', I],
113
+ ['394608004', 'Surgery-Dental-Orthodontics / Dentistry-surgical-Orthodontics', 'LP172934-4', 'Dentistry', W],
114
+ ['408461007', 'Surgery-Dental-Periodontal surgery', 'LP172934-4', 'Dentistry', W],
115
+ ['408460008', 'Prosthodontics / surgical-Prosthodontics', 'LP172934-4', 'Dentistry', W],
116
+ ['394606000', 'Surgery-Dentistry-Restorative dentistry', 'LP172934-4', 'Dentistry', W],
117
+ ['408449004', 'Surgery-Dentistry--surgical', 'LP172934-4', 'Dentistry', W],
118
+ ['418018006', 'Surgery-Dermatologic surgery', 'LP172935-1', 'Dermatology', W],
119
+ ['394604002', 'Surgery-Ear, nose and throat surgery', 'LP172980-7', 'Otolaryngology', I],
120
+ ['394609007', 'Surgery-general', 'LA7205-3', 'Surgery.general', E],
121
+ ['408474001', 'Surgery-Hepatobiliary and pancreatic surgery', 'LP172950-0', 'Hepatology', W],
122
+ ['394610002', 'Surgery-Neurosurgery', 'LP172963-3', 'Neurological surgery', E],
123
+ ['394611003', 'Surgery-Plastic surgery', 'LP173006-0', 'Plastic surgery', E],
124
+ ['408477008', 'Surgery-Transplantation surgery', 'LP173033-4', 'Transplant surgery', E],
125
+ ['394801008', 'Surgery-Trauma and orthopedics', 'LP434806-8', 'Orthopaedic surgery', I],
126
+ ['408463005', 'Surgery-Vascular', 'LP173039-1', 'Vascular surgery', E],
127
+ ['419321007', 'Surgical oncology', 'LP207940-0', 'Surgical oncology', E],
128
+ ['394576009', 'Surgical-Accident & emergency', 'LP172940-1', 'Emergency medicine', I],
129
+ ['394590007', 'Thoracic medicine', 'LP173031-8', 'Thoracic and cardiac surgery', R],
130
+ ['409967009', 'Toxicology', 'LP172957-5', 'Medical toxicology', I],
131
+ ['408448007', 'Tropical medicine', undefined, undefined, U],
132
+ ['419043006', 'Urological oncology', 'LP173036-7', 'Urology', W],
133
+ ['394612005', 'Urology', 'LP173036-7', 'Urology', E],
134
+ ['394733009', 'Medical specialty--OTHER--NOT LISTED', 'LA46-8', 'Other', I],
135
+ ['394732004', 'Surgical specialty--OTHER-NOT LISTED', 'LA46-8', 'Other', I],
136
+ ]);
137
+ export const SnomedC80ToLoincSpecialtyConceptMap = Object.freeze(suppliedRows.map(([snomedCode, snomedDisplay, loincCode, loincDisplay, equivalence = U]) => Object.freeze({
138
+ snomedSystem: 'http://snomed.info/sct',
139
+ snomedCode,
140
+ snomedDisplay,
141
+ ...(loincCode ? { loincSystem: 'http://loinc.org', loincCode, loincDisplay } : {}),
142
+ equivalence,
143
+ automatic: equivalence === E,
144
+ })));
145
+ /** Returns a portable LOINC specialty only for a strict reviewed equivalence. */
146
+ export function findAutomaticLoincSpecialty(snomedCode) {
147
+ const mapping = SnomedC80ToLoincSpecialtyConceptMap.find(item => item.snomedCode === snomedCode);
148
+ if (!mapping?.automatic || !mapping.loincCode || !mapping.loincDisplay)
149
+ return undefined;
150
+ return Object.freeze({ system: 'http://loinc.org', code: mapping.loincCode, display: mapping.loincDisplay });
151
+ }
package/dist/index.d.ts CHANGED
@@ -24,3 +24,5 @@ export * from './fhir-tokens.js';
24
24
  export * from './related-person.js';
25
25
  export * from './imaging-types.js';
26
26
  export * from './subscription-r5.js';
27
+ export * from './healthcare-service-specialty-map.js';
28
+ export * from './appointment-communication-test-data.js';
package/dist/index.js CHANGED
@@ -24,3 +24,5 @@ export * from './fhir-tokens.js';
24
24
  export * from './related-person.js';
25
25
  export * from './imaging-types.js';
26
26
  export * from './subscription-r5.js';
27
+ export * from './healthcare-service-specialty-map.js';
28
+ export * from './appointment-communication-test-data.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
  }