import type { Coding, Extension, Patient } from "fhir/r4"; import type { IValueSet } from "../../services/ValueSetService"; import { enrolledOf, TRIBAL_AFFILIATION_URL, tribalAffiliationExtension } from "../../utils/tribalAffiliation"; // Race, ethnicity and tribal affiliation are US Core extensions; languages are Patient.communication. export const DEMOGRAPHIC_EXTENSIONS = { race: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", ethnicity: "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", tribal: TRIBAL_AFFILIATION_URL, }; export const LANGUAGE_OPTIONS: IValueSet[] = [ { code: "en", display: "English", system: "urn:ietf:bcp:47" }, { code: "es", display: "Spanish", system: "urn:ietf:bcp:47" }, { code: "fr", display: "French", system: "urn:ietf:bcp:47" }, { code: "de", display: "German", system: "urn:ietf:bcp:47" }, { code: "it", display: "Italian", system: "urn:ietf:bcp:47" }, { code: "pt", display: "Portuguese", system: "urn:ietf:bcp:47" }, { code: "zh", display: "Chinese", system: "urn:ietf:bcp:47" }, { code: "ja", display: "Japanese", system: "urn:ietf:bcp:47" }, { code: "ko", display: "Korean", system: "urn:ietf:bcp:47" }, { code: "ar", display: "Arabic", system: "urn:ietf:bcp:47" }, { code: "ru", display: "Russian", system: "urn:ietf:bcp:47" }, { code: "hi", display: "Hindi", system: "urn:ietf:bcp:47" }, { code: "vi", display: "Vietnamese", system: "urn:ietf:bcp:47" }, { code: "tl", display: "Tagalog", system: "urn:ietf:bcp:47" }, { code: "fr-CA", display: "French (Canada)", system: "urn:ietf:bcp:47" }, { code: "other", display: "Other", system: "urn:ietf:bcp:47" }, ]; export interface Demographics { languages: IValueSet[]; races: IValueSet[]; detailedRaces: IValueSet[]; ethnicities: IValueSet[]; detailedEthnicities: IValueSet[]; tribes: IValueSet[]; /** Absent means nobody answered, which is not the same as No. */ enrolled?: boolean; } const codingToValueSet = (coding: Coding): IValueSet => ({ system: coding.system ?? "", code: coding.code ?? "", display: coding.display ?? "", }); const childCodings = (extension: Extension | undefined, url: string): IValueSet[] => (extension?.extension ?? []) .filter((child) => child.url === url && child.valueCoding) .map((child) => codingToValueSet(child.valueCoding as Coding)); const toCoding = (item: IValueSet): Coding => ({ system: item.system, code: item.code, display: item.display }); export const readDemographics = (patient: Patient | undefined): Demographics => { const extensions = patient?.extension ?? []; const race = extensions.find((ext) => ext.url === DEMOGRAPHIC_EXTENSIONS.race); const ethnicity = extensions.find((ext) => ext.url === DEMOGRAPHIC_EXTENSIONS.ethnicity); const tribal = extensions.find((ext) => ext.url === DEMOGRAPHIC_EXTENSIONS.tribal); const tribeCoding = tribal?.extension?.find((child) => child.url === "tribalAffiliation")?.valueCodeableConcept?.coding?.[0]; return { languages: (patient?.communication ?? []) .map((entry) => entry.language?.coding?.[0]) .filter((coding): coding is Coding => Boolean(coding)) .map(codingToValueSet), races: childCodings(race, "ombCategory"), detailedRaces: childCodings(race, "detailed"), ethnicities: childCodings(ethnicity, "ombCategory").slice(0, 1), detailedEthnicities: childCodings(ethnicity, "detailed"), tribes: tribeCoding ? [codingToValueSet(tribeCoding)] : [], enrolled: enrolledOf(tribal), }; }; const sameLanguage = (coding: Coding | undefined, item: IValueSet) => Boolean(coding) && (coding?.code ?? "") === item.code && (coding?.system ?? "") === item.system; /** * Writes the answers onto a Patient draft. Unrelated extensions are kept. No languages means the * communication property is absent: the server refuses an empty array, and absence is what clears it. * * `was`: the answers as the form was filled. Given it, only the parts the person changed are written, onto the * entries as recorded: an unchanged part keeps everything this form does not show (a preferred-language flag, * another coding, a language recorded only as text), and a kept language keeps its own entry. */ export const applyDemographics = (next: Patient, answers: Demographics, was?: Demographics): void => { const changed = (...keys: (keyof Demographics)[]) => !was || keys.some((key) => JSON.stringify(answers[key]) !== JSON.stringify(was[key])); let extensions: Extension[] = [...(next.extension ?? [])]; // In the place it was recorded, so nothing else moves; absent answers remove it. const put = (url: string, extension: Extension | undefined) => { const at = extensions.findIndex((ext) => ext.url === url); extensions = extensions.flatMap((ext, index) => { if (ext.url !== url) return [ext]; return index === at && extension ? [extension] : []; }); if (at < 0 && extension) extensions.push(extension); }; if (changed("races", "detailedRaces")) { let race: Extension | undefined; if (answers.races.length || answers.detailedRaces.length) { const children: Extension[] = [ ...answers.races.map((item) => ({ url: "ombCategory", valueCoding: toCoding(item) })), ...answers.detailedRaces.map((item) => ({ url: "detailed", valueCoding: toCoding(item) })), ]; children.push({ url: "text", valueString: [...answers.races, ...answers.detailedRaces].map((item) => item.display).join(", ") }); race = { url: DEMOGRAPHIC_EXTENSIONS.race, extension: children }; } put(DEMOGRAPHIC_EXTENSIONS.race, race); } if (changed("ethnicities", "detailedEthnicities")) { // US Core allows one ethnicity category. const category = answers.ethnicities[0]; let ethnicity: Extension | undefined; if (category || answers.detailedEthnicities.length) { const children: Extension[] = [ ...(category ? [{ url: "ombCategory", valueCoding: toCoding(category) }] : []), ...answers.detailedEthnicities.map((item) => ({ url: "detailed", valueCoding: toCoding(item) })), ]; children.push({ url: "text", valueString: [...(category ? [category] : []), ...answers.detailedEthnicities].map((item) => item.display).join(", "), }); ethnicity = { url: DEMOGRAPHIC_EXTENSIONS.ethnicity, extension: children }; } put(DEMOGRAPHIC_EXTENSIONS.ethnicity, ethnicity); } if (changed("tribes", "enrolled")) { // US Core allows one tribal affiliation per extension. const tribe = answers.tribes[0]; put(DEMOGRAPHIC_EXTENSIONS.tribal, tribe ? tribalAffiliationExtension(toCoding(tribe), answers.enrolled) : undefined); } next.extension = extensions.length ? extensions : undefined; if (!next.extension) delete next.extension; if (changed("languages")) { const recorded = next.communication ?? []; // A language recorded only as text is not on this form, so it is not this form's to remove. const kept = recorded.filter((entry) => { const coding = entry.language?.coding?.[0]; return !coding || answers.languages.some((item) => sameLanguage(coding, item)); }); const added = answers.languages .filter((item) => !kept.some((entry) => sameLanguage(entry.language?.coding?.[0], item))) .map((item) => ({ language: { coding: [toCoding(item)], text: item.display } })); const communication = [...kept, ...added]; if (communication.length) next.communication = communication; else delete next.communication; } }; export const listDisplays = (items: IValueSet[]): string => items.map((item) => item.display).join(", ");