mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(fhir): export the body site and side of a condition
Condition.bodySite carries the free-text site as text. The side rides a contained BodyStructure reached through the core bodySite extension, coded with the SNOMED CT concepts of the R4 bodysite-laterality value set, and is repeated in the text so a receiver that ignores extensions still reads it.
This commit is contained in:
1 parent
4af6b87a2a
commit
8934a2900e
4 files changed
+269
No files matched your search
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* `Condition.bodySite` from the condition journal's free-text site and side.
|
||||
*
|
||||
* The site rides `text`; the side rides a contained `BodyStructure` reached
|
||||
* through the core `bodySite` extension, and is repeated in `text` so a
|
||||
* receiver that ignores extensions still reads which side.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { conditionsFromReportData } from "@/lib/fhir/resources";
|
||||
import type { FhirCondition } from "@/lib/fhir/types";
|
||||
|
||||
type Episode = NonNullable<DoctorReportData["illnessEpisodes"]>[number];
|
||||
|
||||
function conditionFor(extra: Partial<Episode>): FhirCondition {
|
||||
const data = {
|
||||
illnessEpisodes: [
|
||||
{
|
||||
label: "Sprain",
|
||||
type: "INJURY",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-04-01T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
...extra,
|
||||
},
|
||||
],
|
||||
} as unknown as DoctorReportData;
|
||||
const [condition] = conditionsFromReportData(data).conditions;
|
||||
if (!condition) throw new Error("no Condition emitted");
|
||||
return condition;
|
||||
}
|
||||
|
||||
describe("conditionsFromReportData — bodySite", () => {
|
||||
it("emits no bodySite and nothing contained when no site was recorded", () => {
|
||||
const c = conditionFor({});
|
||||
expect(c.bodySite).toBeUndefined();
|
||||
expect(c.contained).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a blank site as no site, even with a side", () => {
|
||||
const c = conditionFor({ bodySite: " ", laterality: "LEFT" });
|
||||
expect(c.bodySite).toBeUndefined();
|
||||
expect(c.contained).toBeUndefined();
|
||||
});
|
||||
|
||||
it("emits the site as text only when no side was stated", () => {
|
||||
const c = conditionFor({ bodySite: "Lower back", laterality: null });
|
||||
expect(c.bodySite).toEqual([{ text: "Lower back" }]);
|
||||
expect(c.contained).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["LEFT", "419161000", "Unilateral left", "left"],
|
||||
["RIGHT", "419465000", "Unilateral right", "right"],
|
||||
["BOTH", "51440002", "Bilateral", "both sides"],
|
||||
])(
|
||||
"carries %s as SNOMED %s on a contained BodyStructure",
|
||||
(laterality, code, display, sideText) => {
|
||||
const c = conditionFor({ bodySite: "Knee", laterality });
|
||||
expect(c.bodySite).toEqual([
|
||||
{
|
||||
extension: [
|
||||
{
|
||||
url: "http://hl7.org/fhir/StructureDefinition/bodySite",
|
||||
valueReference: { reference: "#bodysite-1" },
|
||||
},
|
||||
],
|
||||
text: `Knee (${sideText})`,
|
||||
},
|
||||
]);
|
||||
expect(c.contained).toEqual([
|
||||
{
|
||||
resourceType: "BodyStructure",
|
||||
id: "bodysite-1",
|
||||
location: { text: "Knee" },
|
||||
locationQualifier: [
|
||||
{
|
||||
coding: [{ system: "http://snomed.info/sct", code, display }],
|
||||
text: sideText,
|
||||
},
|
||||
],
|
||||
patient: { reference: "Patient/patient-1" },
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("never puts the side into bodySite.coding", () => {
|
||||
const c = conditionFor({ bodySite: "Knee", laterality: "RIGHT" });
|
||||
expect(c.bodySite?.[0]?.coding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to the site alone for an unknown side value", () => {
|
||||
const c = conditionFor({ bodySite: "Knee", laterality: "SIDEWAYS" });
|
||||
expect(c.bodySite).toEqual([{ text: "Knee" }]);
|
||||
expect(c.contained).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps each episode's site on its own Condition", () => {
|
||||
const data = {
|
||||
illnessEpisodes: [
|
||||
{
|
||||
label: "A",
|
||||
type: "INJURY",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-04-01T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
bodySite: "Wrist",
|
||||
laterality: "RIGHT",
|
||||
},
|
||||
{
|
||||
label: "B",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-04-02T00:00:00.000Z",
|
||||
resolvedAt: null,
|
||||
},
|
||||
],
|
||||
} as unknown as DoctorReportData;
|
||||
const [a, b] = conditionsFromReportData(data).conditions;
|
||||
expect(a?.bodySite?.[0]?.text).toBe("Wrist (right)");
|
||||
expect(b?.bodySite).toBeUndefined();
|
||||
expect(b?.contained).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,8 @@ function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: "2026-04-01T00:00:00.000Z",
|
||||
resolvedAt: "2026-04-10T00:00:00.000Z",
|
||||
bodySite: "Knee",
|
||||
laterality: "LEFT",
|
||||
},
|
||||
],
|
||||
mood: { avg: 3.8, count: 20 },
|
||||
@@ -173,6 +175,34 @@ function resourcesOf(bundle: FhirBundle): FhirResource[] {
|
||||
return bundle.entry.map((e) => e.resource);
|
||||
}
|
||||
|
||||
// ── Condition.bodySite — the contained BodyStructure resolves in place ───
|
||||
|
||||
describe("Condition.bodySite", () => {
|
||||
it("points its extension at a BodyStructure contained in the same Condition, whose patient resolves", () => {
|
||||
const bundle = fullBundle();
|
||||
const patientUrl = bundle.entry.find(
|
||||
(e) => e.resource.resourceType === "Patient",
|
||||
)?.fullUrl;
|
||||
const condition = resourcesOf(bundle).find(
|
||||
(r) => r.resourceType === "Condition",
|
||||
);
|
||||
if (condition?.resourceType !== "Condition")
|
||||
throw new Error("no Condition");
|
||||
const ext = condition.bodySite?.[0]?.extension?.[0];
|
||||
expect(ext?.url).toBe("http://hl7.org/fhir/StructureDefinition/bodySite");
|
||||
const target = ext?.valueReference.reference;
|
||||
const structure = condition.contained?.find((c) => `#${c.id}` === target);
|
||||
expect(structure?.resourceType).toBe("BodyStructure");
|
||||
expect(structure?.patient.reference).toBe(patientUrl);
|
||||
// A code from the R4 core bodysite-laterality value set.
|
||||
expect(
|
||||
["419161000", "419465000", "51440002"].includes(
|
||||
structure?.locationQualifier?.[0]?.coding?.[0]?.code ?? "",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Test 14 — document identity, entry identity, reference resolution ─────
|
||||
|
||||
describe("document Bundle — identity and resolvability", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { ILLNESS_TYPE_SNOMED } from "@/lib/fhir/illness-snomed";
|
||||
import type {
|
||||
FhirBodyStructure,
|
||||
FhirCodeableConcept,
|
||||
FhirCondition,
|
||||
FhirEncounter,
|
||||
@@ -19,6 +20,83 @@ import { SNOMED_SYSTEM, patientRef } from "@/lib/fhir/resources/common";
|
||||
/** SNOMED CT "Disease (disorder)" — the generic, non-diagnostic root concept. */
|
||||
const DISEASE_SNOMED = { code: "64572001", display: "Disease" } as const;
|
||||
|
||||
/**
|
||||
* The side of a body site, as SNOMED CT laterality qualifiers from the R4 core
|
||||
* value set `http://hl7.org/fhir/ValueSet/bodysite-laterality`
|
||||
* (https://hl7.org/fhir/R4/valueset-bodysite-laterality.html); the same three
|
||||
* concepts sit in `bodystructure-relative-location`, the value set R4 binds to
|
||||
* `BodyStructure.locationQualifier`. Keyed on the `Laterality` enum.
|
||||
*/
|
||||
const LATERALITY_SNOMED: Record<
|
||||
string,
|
||||
{ code: string; display: string; text: string }
|
||||
> = {
|
||||
LEFT: { code: "419161000", display: "Unilateral left", text: "left" },
|
||||
RIGHT: { code: "419465000", display: "Unilateral right", text: "right" },
|
||||
BOTH: { code: "51440002", display: "Bilateral", text: "both sides" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Core extension `http://hl7.org/fhir/StructureDefinition/bodySite`
|
||||
* (https://hl7.org/fhir/extensions/StructureDefinition-bodySite.html): a
|
||||
* Reference(BodyStructure) on `Condition.bodySite`, unchanged in R4.
|
||||
*/
|
||||
const BODY_SITE_EXTENSION_URL =
|
||||
"http://hl7.org/fhir/StructureDefinition/bodySite";
|
||||
|
||||
const BODY_STRUCTURE_ID = "bodysite-1";
|
||||
|
||||
/**
|
||||
* `Condition.bodySite` plus the contained `BodyStructure` that carries its
|
||||
* side, or nothing when no site was recorded.
|
||||
*
|
||||
* The site is the user's free text, so it rides `text` only; no SNOMED body
|
||||
* structure is guessed from it. R4 `Condition.bodySite` has no laterality
|
||||
* element, and a laterality concept is not a body site, so putting "Left" in
|
||||
* `bodySite.coding` would claim the condition sits on "left". The standard R4
|
||||
* route is the core `bodySite` extension pointing at a `BodyStructure` whose
|
||||
* `locationQualifier` holds the SNOMED CT side. The side is also written into
|
||||
* `bodySite.text`, because a receiver that ignores extensions must still read
|
||||
* "Knee (left)" rather than a bare "Knee".
|
||||
*/
|
||||
function bodySiteOf(
|
||||
bodySite: string | null | undefined,
|
||||
laterality: string | null | undefined,
|
||||
): Pick<FhirCondition, "bodySite" | "contained"> {
|
||||
const site = bodySite?.trim();
|
||||
if (!site) return {};
|
||||
const side = laterality ? LATERALITY_SNOMED[laterality] : undefined;
|
||||
if (!side) return { bodySite: [{ text: site }] };
|
||||
const structure: FhirBodyStructure = {
|
||||
resourceType: "BodyStructure",
|
||||
id: BODY_STRUCTURE_ID,
|
||||
location: { text: site },
|
||||
locationQualifier: [
|
||||
{
|
||||
coding: [
|
||||
{ system: SNOMED_SYSTEM, code: side.code, display: side.display },
|
||||
],
|
||||
text: side.text,
|
||||
},
|
||||
],
|
||||
patient: patientRef,
|
||||
};
|
||||
return {
|
||||
contained: [structure],
|
||||
bodySite: [
|
||||
{
|
||||
extension: [
|
||||
{
|
||||
url: BODY_SITE_EXTENSION_URL,
|
||||
valueReference: { reference: `#${BODY_STRUCTURE_ID}` },
|
||||
},
|
||||
],
|
||||
text: `${site} (${side.text})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** R4 `Condition.clinicalStatus` concept for the given resolution state. */
|
||||
function conditionClinicalStatus(resolved: boolean): FhirCodeableConcept {
|
||||
return {
|
||||
@@ -53,9 +131,11 @@ export function conditionsFromReportData(data: DoctorReportData): {
|
||||
seq += 1;
|
||||
const conditionId = `condition-${seq}`;
|
||||
const resolved = ep.resolvedAt !== null;
|
||||
const site = bodySiteOf(ep.bodySite, ep.laterality);
|
||||
conditions.push({
|
||||
resourceType: "Condition",
|
||||
id: conditionId,
|
||||
...(site.contained ? { contained: site.contained } : {}),
|
||||
clinicalStatus: conditionClinicalStatus(resolved),
|
||||
verificationStatus: {
|
||||
coding: [
|
||||
@@ -94,6 +174,7 @@ export function conditionsFromReportData(data: DoctorReportData): {
|
||||
],
|
||||
text: ep.label,
|
||||
},
|
||||
...(site.bodySite ? { bodySite: site.bodySite } : {}),
|
||||
subject: patientRef,
|
||||
onsetDateTime: ep.onsetAt,
|
||||
...(ep.resolvedAt ? { abatementDateTime: ep.resolvedAt } : {}),
|
||||
|
||||
@@ -15,10 +15,21 @@ export interface FhirCoding {
|
||||
}
|
||||
|
||||
export interface FhirCodeableConcept {
|
||||
/**
|
||||
* Element extensions. Only `Condition.bodySite` carries one today (the core
|
||||
* `bodySite` extension pointing at a contained `BodyStructure`).
|
||||
*/
|
||||
extension?: FhirExtension[];
|
||||
coding?: FhirCoding[];
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** R4 `Extension`, narrowed to the one value type HealthLog emits. */
|
||||
export interface FhirExtension {
|
||||
url: string;
|
||||
valueReference: FhirReference;
|
||||
}
|
||||
|
||||
export interface FhirReference {
|
||||
reference: string;
|
||||
display?: string;
|
||||
@@ -250,10 +261,17 @@ export interface FhirComposition {
|
||||
export interface FhirCondition {
|
||||
resourceType: "Condition";
|
||||
id: string;
|
||||
/**
|
||||
* v1.39.3 — the `BodyStructure` that carries the side of the body site,
|
||||
* referenced from `bodySite[].extension` by a local `#`-ref.
|
||||
*/
|
||||
contained?: FhirBodyStructure[];
|
||||
clinicalStatus?: FhirCodeableConcept;
|
||||
verificationStatus?: FhirCodeableConcept;
|
||||
category?: FhirCodeableConcept[];
|
||||
code: FhirCodeableConcept;
|
||||
/** v1.39.3 — where on the body, as the user wrote it (`text` only). */
|
||||
bodySite?: FhirCodeableConcept[];
|
||||
subject: FhirReference;
|
||||
onsetDateTime?: string;
|
||||
abatementDateTime?: string;
|
||||
@@ -261,6 +279,20 @@ export interface FhirCondition {
|
||||
note?: FhirAnnotation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.39.3 — R4 `BodyStructure`, emitted only as a resource contained in a
|
||||
* `Condition`. `location` repeats the free-text site; `locationQualifier`
|
||||
* carries the side as a SNOMED CT laterality concept. `patient` is `1..1` in
|
||||
* R4.
|
||||
*/
|
||||
export interface FhirBodyStructure {
|
||||
resourceType: "BodyStructure";
|
||||
id: string;
|
||||
location?: FhirCodeableConcept;
|
||||
locationQualifier?: FhirCodeableConcept[];
|
||||
patient: FhirReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.1 P4 — R4 `Encounter`. Emitted alongside a resolved episode to bound
|
||||
* the period of care (onset → resolution) so a clinician sees the episode as a
|
||||
|
||||
Reference in new issue
Block a user