Files
HealthLog/src/lib/fhir/resources.ts
T

1122 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.11.0 — shared per-resource FHIR R4 emitters.
*
* The Observation / MedicationStatement / MedicationAdministration / Patient /
* Coverage builders (with their LOINC/ATC/SNOMED/UCUM codings + the `survey`
* wellness split) live here as small pure functions over the SAME
* `DoctorReportData` the document-bundle builder consumes.
*
* Two callers share these emitters: `buildFhirDocumentBundle` composes them
* into a `type: "document"` Bundle, and the FHIR REST search routes wrap them
* in a `type: "searchset"` Bundle. Keeping the coding logic in one place means
* the LOINC/UCUM/ATC mapping has exactly one home — the document export and
* the REST face can never drift apart.
*
* No FHIR SDK, no `@types/fhir` — narrow hand-rolled interfaces only
* (`./types`), matching the project's "hand-rolled over the documented wire"
* convention. All text is escaped plain text; never user-supplied HTML
* (no markdown library, no `dangerouslySetInnerHTML`).
*/
import {
adherenceRatePercent,
type DoctorReportData,
} from "@/lib/doctor-report-data";
import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose";
import {
LOINC_SYSTEM,
HEALTHKIT_CODESYSTEM,
UCUM_SYSTEM,
MEASUREMENT_LOINC,
BP_PANEL_LOINC,
BP_SYS_LOINC,
BP_DIA_LOINC,
BP_UNIT,
GLUCOSE_LOINC,
GLUCOSE_TIR_LOINC,
GLUCOSE_GMI_LOINC,
GLUCOSE_MEAN_LOINC,
GLUCOSE_EA1C_LOINC,
MEDICATION_ADHERENCE_LOINC,
MOOD_LOINC,
LMP_LOINC,
CYCLE_LENGTH_LOINC,
PERIOD_LENGTH_LOINC,
type LoincMapping,
} from "@/lib/fhir/loinc-map";
import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
import { resolveLabCoding } from "@/lib/fhir/lab-loinc";
import type {
FhirCodeableConcept,
FhirObservation,
FhirMedicationStatement,
FhirMedicationAdministration,
FhirDosage,
FhirPatient,
FhirCoverage,
FhirCondition,
FhirEncounter,
FhirOrganization,
FhirReference,
} from "@/lib/fhir/types";
/** Patient identity not carried in `DoctorReportData` (KVNR is encrypted). */
export interface FhirPatientIdentity {
/** German KVNR (decrypted by the route). */
insuranceNumber: string | null;
}
/** KVNR identifier namespace per the gematik SID. */
const KVNR_SYSTEM = "http://fhir.de/sid/gkv/kvid-10";
/** German insurer institution-number (IKNR) identifier namespace. */
const IKNR_SYSTEM = "http://fhir.de/sid/arge-ik/iknr";
/**
* v1.9.0 — drug-coding system URIs. ATC is the portable WHO default
* (the iOS export emits the identical URI); RxNorm is the secondary US
* coding. Both are additive `coding[]` entries on the same concept; the
* free-text `.text` (the user's medication name) stays the anchor.
*/
export const ATC_SYSTEM = "http://www.whocc.no/atc";
/**
* German national ATC URI maintained by the BfArM. Same ATC classification
* as WHO under a national CodeSystem; emitted as an ADDITIONAL coding (never
* a replacement) when a German-region export is requested, so the WHO entry
* stays first and byte-identical for every consumer.
*/
const ATC_BFARM_SYSTEM = "http://fhir.de/CodeSystem/bfarm/atc";
const RXNORM_SYSTEM = "http://www.nlm.nih.gov/research/umls/rxnorm";
/** SNOMED CT URI. Concept ids are referenced (not redistributed) in FHIR instances. */
export const SNOMED_SYSTEM = "http://snomed.info/sct";
/**
* The app locales for which a health-record export defaults `germanAtc` on
* (the additive BfArM ATC coding). The export route derives the flag from
* the user's locale against this set; the capabilities endpoint surfaces it
* verbatim so a client can predict the coding without a round-trip. Keeping
* it here — beside the BfArM URI it gates — makes the two move together.
*/
export const GERMAN_ATC_DEFAULT_LOCALES = ["de"] as const;
/** Options threaded from the export route into the emitters. Additive, all defaulted. */
export interface FhirBuildOptions {
/**
* When true, additionally emit the German BfArM ATC URI alongside the WHO
* entry on each medication concept. The WHO coding stays first and
* byte-identical; this only appends a second URI for the same leaf code.
* Defaults off; the route turns it on for a German-region export.
*/
germanAtc?: boolean;
}
/**
* Build a `medicationCodeableConcept` from a medication's free-text name
* plus its optional user-asserted codes. ATC is emitted first (primary),
* RxNorm second (secondary); both are omitted when NULL, collapsing to
* exactly the pre-v1.9.0 `{ text }` shape. Never machine-guesses a code.
* When `germanAtc` is set, the same ATC leaf code is also published under
* the BfArM URI AFTER the WHO entry — additive, never reordering WHO.
*/
function medicationConcept(
name: string,
atcCode: string | null | undefined,
rxNormCode: string | null | undefined,
germanAtc: boolean,
): FhirCodeableConcept {
const coding: NonNullable<FhirCodeableConcept["coding"]> = [];
if (atcCode) {
coding.push({ system: ATC_SYSTEM, code: atcCode, display: name });
if (germanAtc) {
coding.push({ system: ATC_BFARM_SYSTEM, code: atcCode, display: name });
}
}
if (rxNormCode) {
coding.push({ system: RXNORM_SYSTEM, code: rxNormCode });
}
return coding.length > 0 ? { coding, text: name } : { text: name };
}
/**
* v1.9.0 — UCUM codes for the dose units HealthLog stores. The display
* `unit` is always the user's original string; the UCUM `code` is set
* only for an unambiguous mapping so a consumer that resolves UCUM never
* sees a guessed code. An unmapped unit drops `code` (and `system`) and
* keeps just the human-readable `unit` — conformant, just not coded.
*/
const UCUM_DOSE_CODES: Record<string, string> = {
mg: "mg",
g: "g",
mcg: "ug",
µg: "ug",
ug: "ug",
ml: "mL",
mL: "mL",
};
function doseQuantity(
value: number,
unit: string,
): {
value: number;
unit: string;
system?: string;
code?: string;
} {
const ucum = UCUM_DOSE_CODES[unit];
return ucum
? { value, unit, system: UCUM_SYSTEM, code: ucum }
: { value, unit };
}
/**
* Route of administration derived from the medication's delivery form,
* carrying an additive SNOMED CT `coding` alongside the existing `.text`
* anchor. HealthLog injections are subcutaneous (the injection-site picker
* exists for the GLP-1 / self-injection workflow), so `INJECTION` maps to
* the subcutaneous route. Returns `undefined` for an unknown / absent form
* so no empty route is emitted.
*/
const ROUTE_SNOMED: Record<string, { code: string; display: string }> = {
ORAL: { code: "26643006", display: "Oral route" },
INJECTION: { code: "34206005", display: "Subcutaneous route" },
};
function routeConcept(
deliveryForm: string | null,
): FhirCodeableConcept | undefined {
const text =
deliveryForm === "ORAL"
? "Oral"
: deliveryForm === "INJECTION"
? "Injection"
: undefined;
if (!text) return undefined;
const snomed = ROUTE_SNOMED[deliveryForm as string];
return snomed
? {
coding: [
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
],
text,
}
: { text };
}
/**
* Administration body-site keyed on the raw `InjectionSite` enum value,
* carrying an additive SNOMED CT body-region `coding` alongside the `.text`
* anchor. The map collapses the eight enum members to three gross body-region
* concepts (abdomen / thigh / upper arm); laterality (left/right) and the
* abdominal quadrant are NOT lateralised SNOMED concepts here — they are
* preserved verbatim in the human-readable `.text` (the raw enum value), so
* no information is lost.
*/
const SITE_SNOMED: Record<string, { code: string; display: string }> = {
ABDOMEN_LEFT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_RIGHT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_UPPER_LEFT: { code: "818983003", display: "Abdomen structure" },
ABDOMEN_UPPER_RIGHT: { code: "818983003", display: "Abdomen structure" },
THIGH_LEFT: { code: "68367000", display: "Thigh structure" },
THIGH_RIGHT: { code: "68367000", display: "Thigh structure" },
UPPER_ARM_LEFT: { code: "40983000", display: "Structure of upper arm" },
UPPER_ARM_RIGHT: { code: "40983000", display: "Structure of upper arm" },
};
function siteConcept(injectionSite: string): FhirCodeableConcept {
const snomed = SITE_SNOMED[injectionSite];
// Preserve the full enum value (incl. laterality) as the readable anchor.
const text = injectionSite;
return snomed
? {
coding: [
{ system: SNOMED_SYSTEM, code: snomed.code, display: snomed.display },
],
text,
}
: { text };
}
function codeableFromMapping(m: LoincMapping): FhirCodeableConcept {
if (m.loinc) {
// HealthKit placeholder codes have no published LOINC term; they must not
// sit under the LOINC namespace (a non-LOINC code there is a conformance
// violation). Route them onto the shared custom CodeSystem instead —
// byte-aligned with the iOS exporter.
const system = m.loinc.startsWith("HKQuantityTypeIdentifier")
? HEALTHKIT_CODESYSTEM
: LOINC_SYSTEM;
return {
coding: [{ system, code: m.loinc, display: m.display }],
text: m.display,
};
}
// No stable LOINC — local text-only concept (documented fallback).
return { text: m.display };
}
/** v1.10.0 — English display per persisted wellness-score type (FHIR
* text-only concept; the score has no published LOINC term). */
const WELLNESS_SCORE_DISPLAY: Record<string, string> = {
RECOVERY_SCORE: "Recovery score",
STRESS_SCORE: "Stress score",
STRAIN_SCORE: "Strain score",
};
function categoryConcept(category: string): FhirCodeableConcept {
return {
coding: [
{
system: "http://terminology.hl7.org/CodeSystem/observation-category",
code: category,
},
],
};
}
/** Latest `{ value, measuredAt }` for a type, or null when no rows. */
function latestReading(
data: DoctorReportData,
type: string,
): { value: number; measuredAt: string } | null {
const series = data.measurements[type];
if (!series || series.length === 0) return null;
return series[series.length - 1];
}
/** Local-`#`-ref / patient anchor id shared by every subject reference. */
export const PATIENT_RESOURCE_ID = "patient-1";
const patientRef: FhirReference = {
reference: `Patient/${PATIENT_RESOURCE_ID}`,
};
/**
* Emit the `Patient` resource from the aggregated report data + decrypted
* identity. Carries display name, gender, birth date and the KVNR identifier
* when present; every absent field collapses its slot.
*/
export function patientResource(
data: DoctorReportData,
identity: FhirPatientIdentity,
): FhirPatient {
const patient: FhirPatient = {
resourceType: "Patient",
id: PATIENT_RESOURCE_ID,
};
const displayName = data.patient.fullName ?? data.patient.username ?? null;
if (displayName) patient.name = [{ text: displayName }];
if (data.patient.gender) {
patient.gender = data.patient.gender === "MALE" ? "male" : "female";
}
if (data.patient.dateOfBirth) {
patient.birthDate = data.patient.dateOfBirth.slice(0, 10);
}
if (identity.insuranceNumber) {
patient.identifier = [
{ system: KVNR_SYSTEM, value: identity.insuranceNumber },
];
}
return patient;
}
/**
* Emit the `Coverage` resource, or `null` when there is no payor signal at
* all. v1.9.0 — emitted whenever ANY payor signal is present: an insurer
* name, an IKNR, OR a bare KVNR. The KVNR-only case aligns the server with
* the iOS exporter (which emits a Coverage on a bare member id); it carries
* the `subscriberId` with no contained payor Organization. When an insurer
* name and/or IKNR is known, the payor is a CONTAINED Organization referenced
* by a local `#`-ref. The KVNR stays on `Patient.identifier` and doubles as
* the `subscriberId` (member id).
*/
export function coverageResource(
data: DoctorReportData,
identity: FhirPatientIdentity,
): FhirCoverage | null {
const insurerName = data.patient.insurerName ?? null;
const insurerIkNumber = data.patient.insurerIkNumber ?? null;
const hasPayorOrg = Boolean(insurerName || insurerIkNumber);
if (!hasPayorOrg && !identity.insuranceNumber) return null;
const coverage: FhirCoverage = {
resourceType: "Coverage",
id: "coverage-1",
status: "active",
beneficiary: patientRef,
};
if (hasPayorOrg) {
const orgId = "insurer-org-1";
const payorOrg: FhirOrganization = {
resourceType: "Organization",
id: orgId,
};
if (insurerIkNumber) {
payorOrg.identifier = [{ system: IKNR_SYSTEM, value: insurerIkNumber }];
}
if (insurerName) payorOrg.name = insurerName;
coverage.contained = [payorOrg];
coverage.payor = [{ reference: `#${orgId}` }];
}
if (identity.insuranceNumber) {
coverage.subscriberId = identity.insuranceNumber;
}
return coverage;
}
/**
* Emit every `Observation` from the aggregated report data, in the canonical
* order: one latest reading per measurement type, the blood-pressure panel,
* the computed BMI, glucose per context, medication adherence, the opt-in
* mood average, then the descriptive wellness composites under `survey`.
*
* The `obs-N` ids run as one continuous sequence across all of these so the
* document builder's references stay stable; a `searchset` caller can filter
* the returned array by `category`/`code` without re-numbering.
*/
export function observationsFromReportData(
data: DoctorReportData,
_identity: FhirPatientIdentity,
options: FhirBuildOptions = {},
): FhirObservation[] {
void options;
const observations: FhirObservation[] = [];
let obsSeq = 0;
const push = (obs: FhirObservation) => observations.push(obs);
// --- Vital-sign + activity Observations (one latest reading per type) --
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
for (const [type, mapping] of Object.entries(MEASUREMENT_LOINC)) {
// BMI is emitted once by the computed-BMI block below (matching the PDF's
// BMI line); skip the stored BODY_MASS_INDEX series here to avoid a
// duplicate Observation.
if (type === "BODY_MASS_INDEX") continue;
const reading = latestReading(data, type);
if (!reading) continue;
// SLEEP_DURATION is stored in MINUTES; the iOS-locked UCUM unit is `h`, so
// emit the value in hours to keep value and unit consistent. The PDF reads
// the raw series independently and is unaffected.
const value =
type === "SLEEP_DURATION"
? Math.round((reading.value / 60) * 100) / 100
: reading.value;
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept(mapping.category)],
code: codeableFromMapping(mapping),
subject: patientRef,
effectiveDateTime: reading.measuredAt,
valueQuantity: {
value,
unit: mapping.unit,
system: UCUM_SYSTEM,
code: mapping.unit,
},
});
}
// --- Blood-pressure panel (sys + dia components) -----------------------
const sys = latestReading(data, "BLOOD_PRESSURE_SYS");
const dia = latestReading(data, "BLOOD_PRESSURE_DIA");
if (sys && dia) {
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("vital-signs")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: BP_PANEL_LOINC,
display: "Blood pressure panel",
},
],
text: "Blood pressure",
},
subject: patientRef,
// Use the systolic reading's timestamp as the panel effective time.
effectiveDateTime: sys.measuredAt,
component: [
{
code: {
coding: [
{ system: LOINC_SYSTEM, code: BP_SYS_LOINC, display: "Systolic" },
],
},
valueQuantity: {
value: sys.value,
unit: BP_UNIT,
system: UCUM_SYSTEM,
code: BP_UNIT,
},
},
{
code: {
coding: [
{
system: LOINC_SYSTEM,
code: BP_DIA_LOINC,
display: "Diastolic",
},
],
},
valueQuantity: {
value: dia.value,
unit: BP_UNIT,
system: UCUM_SYSTEM,
code: BP_UNIT,
},
},
],
});
}
// --- Computed BMI Observation (matches the PDF's BMI line) -------------
if (data.bmi !== null && data.bmi !== undefined) {
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("vital-signs")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: "39156-5",
display: "Body mass index (BMI) [Ratio]",
},
],
text: "Body mass index (BMI) [Ratio]",
},
subject: patientRef,
effectiveDateTime: data.period.end,
valueQuantity: {
value: data.bmi,
unit: "kg/m2",
system: UCUM_SYSTEM,
code: "kg/m2",
},
});
}
// --- Glucose Observations (per context, user display unit) -------------
for (const [ctx, stat] of Object.entries(data.glucoseStats)) {
const map = GLUCOSE_LOINC[ctx];
if (!map) continue;
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("laboratory")],
code: {
coding: [
{ system: LOINC_SYSTEM, code: map.loinc, display: map.display },
],
text: map.display,
},
subject: patientRef,
effectiveDateTime: data.period.end,
valueQuantity: {
value: convertGlucose(stat.latest, glucoseUnit),
unit: glucoseUnit,
system: UCUM_SYSTEM,
code: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL",
},
});
}
// --- Clinical glucose-panel Observations (v1.18.0) ---------------------
// The server-computed TIR / GMI / estimated-A1C / mean / CV% over the report
// period, by the one literature-locked engine the insights panel + coach +
// PDF consume. Emitted only when readings exist (the aggregator zeroes the
// panel when the glucose module is off, so an absent panel = module off OR no
// data). Each carries the published LOINC where one exists; CV% has none and
// rides a `survey` text-only concept. Percent metrics use UCUM `%`.
const clinical = data.glucoseClinical;
if (clinical && clinical.readingCount > 0) {
const pushClinical = (opts: {
loinc?: string;
text: string;
value: number;
unit: string;
ucum: string;
survey?: boolean;
}) => {
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept(opts.survey ? "survey" : "laboratory")],
code: opts.loinc
? {
coding: [
{ system: LOINC_SYSTEM, code: opts.loinc, display: opts.text },
],
text: opts.text,
}
: { text: opts.text },
subject: patientRef,
effectiveDateTime: data.period.end,
valueQuantity: {
value: opts.value,
unit: opts.unit,
system: UCUM_SYSTEM,
code: opts.ucum,
},
...(clinical.isSpotEstimate
? {
note: [
{
text: "Spot-reading estimate from individual measurements, not a continuous-monitor profile.",
},
],
}
: {}),
});
};
if (clinical.distribution) {
pushClinical({
loinc: GLUCOSE_TIR_LOINC,
text: "Glucose time in range (70–180 mg/dL)",
value: Math.round(clinical.distribution.tir * 1000) / 10,
unit: "%",
ucum: "%",
});
}
if (clinical.meanMgdl !== null) {
pushClinical({
loinc: GLUCOSE_MEAN_LOINC,
text: "Mean glucose",
value: convertGlucose(clinical.meanMgdl, glucoseUnit),
unit: glucoseUnit,
ucum: glucoseUnit === "mmol/L" ? "mmol/L" : "mg/dL",
});
}
if (clinical.gmi !== null) {
pushClinical({
loinc: GLUCOSE_GMI_LOINC,
text: "Glucose Management Indicator (GMI)",
value: Math.round(clinical.gmi * 10) / 10,
unit: "%",
ucum: "%",
});
}
if (clinical.estimatedA1c !== null) {
pushClinical({
loinc: GLUCOSE_EA1C_LOINC,
text: "Estimated A1C",
value: Math.round(clinical.estimatedA1c * 10) / 10,
unit: "%",
ucum: "%",
});
}
if (clinical.variability !== null) {
pushClinical({
text: "Glucose variability (coefficient of variation)",
value: Math.round(clinical.variability.cv * 10) / 10,
unit: "%",
ucum: "%",
survey: true,
});
}
}
// --- Lab-result Observations (v1.17.1; LOINC + UCUM v1.18.8) -----------
// One `laboratory` Observation per recorded analyte. `analyte` + `unit` are
// user-recorded free text (no closed LOINC enum). v1.18.8 — a curated
// analyte→LOINC map (`resolveLabCoding`) adds a real LOINC `coding` ALONGSIDE
// the user's `code.text` for the common biomarkers, and stamps the canonical
// UCUM `code` on the value WHEN the recorded unit matches the mapped
// canonical symbol. An analyte that does NOT resolve keeps the honest
// text-only `code` + display-only `unit` (no fabricated terminology). The
// lab's reference bounds map onto R4 `referenceRange`.
for (const lab of data.labResults ?? []) {
obsSeq += 1;
const referenceRange =
lab.referenceLow !== null || lab.referenceHigh !== null
? [
{
...(lab.referenceLow !== null
? { low: { value: lab.referenceLow, unit: lab.unit } }
: {}),
...(lab.referenceHigh !== null
? { high: { value: lab.referenceHigh, unit: lab.unit } }
: {}),
},
]
: undefined;
// Forward-compat: when the v1.19.0 Biomarker catalog lands and the DTO
// carries a resolved canonical analyte name, thread it as the third arg so
// the catalog name wins over the free-text `analyte`. Absent today.
const coding = resolveLabCoding(lab.analyte, lab.unit);
const text = lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("laboratory")],
code: coding
? {
coding: [
{
system: LOINC_SYSTEM,
code: coding.loinc,
display: coding.display,
},
],
text,
}
: { text },
subject: patientRef,
effectiveDateTime: lab.takenAt,
valueQuantity: {
value: lab.value,
unit: lab.unit,
system: UCUM_SYSTEM,
// Canonical UCUM `code` only when the recorded unit matches; otherwise
// the display `unit` stands alone (no coerced symbol).
...(coding?.ucum ? { code: coding.ucum } : {}),
},
...(referenceRange ? { referenceRange } : {}),
});
}
// --- Medication-adherence Observations (one per medication) ------------
for (const [name, comp] of Object.entries(data.compliance)) {
if (comp.total <= 0) continue;
obsSeq += 1;
// Integer percent — the one canonical rounding the app card + PDF use, so
// a clinician sees the same adherence figure on every surface.
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("activity")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: MEDICATION_ADHERENCE_LOINC,
display: "Medication adherence",
},
],
text: `Medication adherence — ${name}`,
},
subject: patientRef,
effectiveDateTime: data.period.end,
valueQuantity: { value: rate, unit: "%", system: UCUM_SYSTEM, code: "%" },
});
}
// --- Mood Observation (opt-in only; absent when toggle off) ------------
if (data.mood) {
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("vital-signs")],
code: {
coding: [{ system: LOINC_SYSTEM, code: MOOD_LOINC, display: "Mood" }],
text: "Mood (average over period)",
},
subject: patientRef,
effectiveDateTime: data.period.end,
valueQuantity: {
value: Math.round(data.mood.avg * 10) / 10,
unit: "{score}",
system: UCUM_SYSTEM,
code: "{score}",
},
});
}
// --- Wellness-score Observations (descriptive composites) --------------
// v1.10.0 — the server-derived nightly scores (recovery / stress /
// strain). They have no published LOINC term and are NOT clinical
// findings, so each is emitted under the `survey` category with a
// text-only concept and an explicit "descriptive, not a clinical
// assessment" note — a physician's FHIR viewer never mistakes a band for
// a diagnosis. Absent when the aggregator emitted no scores.
if (data.wellnessScores && data.wellnessScores.length > 0) {
for (const s of data.wellnessScores) {
obsSeq += 1;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,
status: "final",
category: [categoryConcept("survey")],
code: { text: WELLNESS_SCORE_DISPLAY[s.type] ?? "Wellness score" },
subject: patientRef,
effectiveDateTime: s.latestAt,
valueQuantity: {
value: s.latest,
unit: "{score}",
system: UCUM_SYSTEM,
code: "{score}",
},
note: [
{
text: "Descriptive wellness score (0–100) computed from tracked signals; not a clinical assessment or diagnosis.",
},
],
});
}
}
return observations;
}
/**
* Phase → SNOMED-free display string for the current-phase Observation.
* The four cycle phases have no single published LOINC answer-list term we
* commit to, so the phase rides a text-only concept under the `survey`
* category (a clinician's viewer reads it as a descriptive finding, not a
* coded diagnosis), mirroring the wellness-score stance.
*/
const CYCLE_PHASE_DISPLAY: Record<string, string> = {
MENSTRUAL: "Menstrual phase",
FOLLICULAR: "Follicular phase",
OVULATORY: "Ovulatory phase",
LUTEAL: "Luteal phase",
};
/**
* v1.15.0 — emit the cycle / reproductive-health Observations from the
* opt-in cycle summary: LMP (LOINC 8665-2, a date value), average cycle
* length (64700-8, days), average period length (64698-4, days), and the
* current phase (text-only survey finding). Absent when the aggregator
* carried no cycle summary (toggle off or no observed cycle). Ids run
* `obs-cycle-N` so they never collide with the main `obs-N` sequence.
*/
export function cycleObservationsFromReportData(
data: DoctorReportData,
): FhirObservation[] {
const cycle = data.cycle;
if (!cycle) return [];
const observations: FhirObservation[] = [];
let seq = 0;
const next = () => `obs-cycle-${(seq += 1)}`;
const effective = data.period.end;
if (cycle.lastPeriodStart) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: LMP_LOINC,
display: "Last menstrual period start date",
},
],
text: "Last menstrual period (LMP)",
},
subject: patientRef,
effectiveDateTime: effective,
// LMP is a date value; emit the YYYY-MM-DD as a FHIR `date`.
valueDateTime: cycle.lastPeriodStart,
});
}
if (cycle.averageCycleLengthDays !== null) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: CYCLE_LENGTH_LOINC,
display: "Menstrual cycle length",
},
],
text: "Average menstrual cycle length",
},
subject: patientRef,
effectiveDateTime: effective,
valueQuantity: {
value: cycle.averageCycleLengthDays,
unit: "d",
system: UCUM_SYSTEM,
code: "d",
},
...(cycle.cycleLengthVariabilityDays !== null
? {
note: [
{
text: `Cycle-length variability (median absolute deviation): ±${cycle.cycleLengthVariabilityDays} days over ${cycle.observedCycleCount} observed cycle(s).`,
},
],
}
: {}),
});
}
if (cycle.averagePeriodLengthDays !== null) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: {
coding: [
{
system: LOINC_SYSTEM,
code: PERIOD_LENGTH_LOINC,
display: "Length of menses",
},
],
text: "Average period length",
},
subject: patientRef,
effectiveDateTime: effective,
valueQuantity: {
value: cycle.averagePeriodLengthDays,
unit: "d",
system: UCUM_SYSTEM,
code: "d",
},
});
}
if (cycle.currentPhase) {
observations.push({
resourceType: "Observation",
id: next(),
status: "final",
category: [categoryConcept("survey")],
code: { text: "Current menstrual cycle phase" },
subject: patientRef,
effectiveDateTime: effective,
valueString:
CYCLE_PHASE_DISPLAY[cycle.currentPhase] ?? cycle.currentPhase,
note: [
{
text: "Phase derived from logged cycle boundaries; descriptive, not a clinical assessment.",
},
],
});
}
return observations;
}
/**
* SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept.
* HealthLog records a patient-kept condition journal; it is NOT a diagnosing
* device and asserts no specific ICD/SNOMED diagnosis. The user's own label
* rides `code.text`. v1.18.8 — the coded `code` now carries the BROAD SNOMED
* CT category concept for the episode's `IllnessType` (see
* `ILLNESS_TYPE_SNOMED`); an unknown / future type falls back to this generic
* root so a downstream system never reads a fabricated SPECIFIC diagnosis off
* the export. The `verificationStatus: unconfirmed` + "patient-reported"
* guard rails stay on every Condition.
*/
const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
return {
coding: [
{
system: "http://terminology.hl7.org/CodeSystem/condition-clinical",
code: resolved ? "resolved" : "active",
},
],
};
}
/**
* v1.18.1 P4 — emit one `Condition` per illness/condition episode plus a
* bounding `Encounter`. Absent unless the illness module is enabled AND the
* window held an episode (the aggregator only populates `illnessEpisodes`
* then). Ids run `condition-1..N` / `encounter-1..N`; the Encounter references
* its Condition so a clinician sees the time-boxed episode. No specific
* diagnosis is coded — see `DISEASE_SNOMED`.
*/
export function conditionsFromReportData(data: DoctorReportData): {
conditions: FhirCondition[];
encounters: FhirEncounter[];
} {
const episodes = data.illnessEpisodes;
if (!episodes || episodes.length === 0) {
return { conditions: [], encounters: [] };
}
const conditions: FhirCondition[] = [];
const encounters: FhirEncounter[] = [];
let seq = 0;
for (const ep of episodes) {
seq += 1;
const conditionId = `condition-${seq}`;
const resolved = ep.resolvedAt !== null;
conditions.push({
resourceType: "Condition",
id: conditionId,
clinicalStatus: conditionClinicalStatus(resolved),
verificationStatus: {
coding: [
{
system:
"http://terminology.hl7.org/CodeSystem/condition-ver-status",
// Patient-reported journal entry → "unconfirmed", never a
// clinician-confirmed diagnosis.
code: "unconfirmed",
},
],
},
category: [
{
coding: [
{
system:
"http://terminology.hl7.org/CodeSystem/condition-category",
code: "problem-list-item",
},
],
// The journal's broad class as a human-readable category label.
text: ep.type,
},
],
code: {
// Broad SNOMED CT category for the journal type; the generic "Disease"
// root is the honest fallback for an unknown / future type. NEVER a
// specific diagnosis — the user's own label stays on `.text`.
coding: [
{
system: SNOMED_SYSTEM,
code: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).code,
display: (ILLNESS_TYPE_SNOMED[ep.type] ?? DISEASE_SNOMED).display,
},
],
text: ep.label,
},
subject: patientRef,
onsetDateTime: ep.onsetAt,
...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
note: [
{
text: `Self-recorded ${ep.lifecycle.toLowerCase().replace(/_/g, " ")} condition journal entry; patient-reported, not a clinical diagnosis.`,
},
],
});
encounters.push({
resourceType: "Encounter",
id: `encounter-${seq}`,
status: resolved ? "finished" : "in-progress",
class: {
system: "http://terminology.hl7.org/CodeSystem/v3-ActCode",
code: "AMB",
display: "ambulatory",
},
subject: patientRef,
period: {
start: ep.onsetAt,
...(ep.resolvedAt ? { end: ep.resolvedAt } : {}),
},
reasonReference: [{ reference: `Condition/${conditionId}` }],
});
}
return { conditions, encounters };
}
/**
* Emit one `MedicationStatement` per active medication. v1.9.0 — additive
* ATC (primary) / RxNorm (secondary) codings when the user stored them;
* falls back to the text-only concept otherwise. Ids run `med-1..N`.
*/
export function medicationStatementsFromReportData(
data: DoctorReportData,
options: FhirBuildOptions = {},
): FhirMedicationStatement[] {
const germanAtc = options.germanAtc ?? false;
const statements: FhirMedicationStatement[] = [];
let medSeq = 0;
for (const med of data.medications) {
medSeq += 1;
const id = `med-${medSeq}`;
const stmt: FhirMedicationStatement = {
resourceType: "MedicationStatement",
id,
status: "active",
medicationCodeableConcept: medicationConcept(
med.name,
med.atcCode,
med.rxNormCode,
germanAtc,
),
subject: patientRef,
};
if (med.dose) stmt.dosage = [{ text: med.dose }];
statements.push(stmt);
}
return statements;
}
/**
* Emit one `MedicationAdministration` per acted intake: `completed` (taken)
* or `not-done` (explicitly skipped). Pending / missed slots and soft-deleted
* tombstones are excluded upstream by the aggregator. The concept reuses the
* same ATC/RxNorm coding as the statement so each administration is
* self-describing without resolving a reference (no `partOf` / `request`
* coupling). A `dosage` is emitted ONLY when a structured `dose` Quantity is
* available; a dosage with only `.text` would violate the R4 dose-or-rate
* invariant. Ids run `medadmin-1..N`.
*/
export function medicationAdministrationsFromReportData(
data: DoctorReportData,
options: FhirBuildOptions = {},
): FhirMedicationAdministration[] {
const germanAtc = options.germanAtc ?? false;
const administrations: FhirMedicationAdministration[] = [];
let adminSeq = 0;
for (const admin of data.medicationAdministrations ?? []) {
adminSeq += 1;
const id = `medadmin-${adminSeq}`;
const resource: FhirMedicationAdministration = {
resourceType: "MedicationAdministration",
id,
status: admin.status,
medicationCodeableConcept: medicationConcept(
admin.medicationName,
admin.atcCode,
admin.rxNormCode,
germanAtc,
),
subject: patientRef,
effectiveDateTime: admin.effectiveAt,
};
// Dosage: only when a structured dose Quantity exists. Carry the
// free-text dose + route + site alongside it. Route and site each carry
// an additive SNOMED coding plus the existing `.text` anchor.
if (admin.dose) {
const dosage: FhirDosage = {
dose: doseQuantity(admin.dose.value, admin.dose.unit),
};
if (admin.doseText) dosage.text = admin.doseText;
const route = routeConcept(admin.deliveryForm);
if (route) dosage.route = route;
if (admin.injectionSite) dosage.site = siteConcept(admin.injectionSite);
resource.dosage = dosage;
}
administrations.push(resource);
}
return administrations;
}