import type { Patient } from 'fhir/r4'; /** * - WHETHER A BLANK FIELD WAS EVER ASKED ABOUT. * * A receptionist reading an em-dash cannot tell three different situations apart, and they lead to three * different actions: * * we never asked a gap to close - ask at the desk * they have none settled, stop asking * they declined settled, and asking again is worse than useless * * **THE FENCE, and it is the whole integrity of the feature:** "None" or "Declined" may render ONLY * where the absence was actually recorded. A field nobody asked about **keeps the dash**. Rendering an * answer against the mere absence of a value would make every unasked field claim the patient declined - * **strictly worse than today**, because today's dash is honestly ambiguous and that would be confidently * wrong. * * THE VOCABULARY IS FHIR'S; ONLY THE ADDRESSING IS OURS * * The card's instruction was to extend the existing convention rather than invent one. The clinical side * already reads and writes **`dataAbsentReason`** - but that is a first-class ELEMENT on `Observation` * and `FamilyMemberHistory`, and `Patient` has no such element. FHIR's answer for the rest of the spec is * the **`data-absent-reason` extension**, and where it hangs depends on the element's type: * * | element | shape | where the extension goes | * |---|---|---| * | `birthDate`, `gender` | primitive | the sibling `_birthDate` / `_gender` object | * | `maritalStatus` | CodeableConcept | on the element itself, with no `coding` | * | `telecom`, `address` | array | an entry that identifies itself and carries no value | * * **That heterogeneity is exactly why this file exists.** Every surface asks one question - * `absenceOf(patient, 'email')` - and none of them has to know that email lives in an array, marital * status is a CodeableConcept, and date of birth is a primitive with a shadow twin. * * **`_birthDate` IS NOT A TYPO AND NOT OURS.** FHIR represents a primitive's metadata in a sibling * property whose name is the element's with an underscore prefix. It looks like a private field and it is * part of the specification. */ /** FHIR's own extension for "this is absent, and here is why". */ export const DATA_ABSENT_REASON_URL = 'http://hl7.org/fhir/StructureDefinition/data-absent-reason'; /** * The three codes this product uses, out of FHIR's larger set. * * `not-asked` is DELIBERATELY NOT ONE OF THEM. Never having asked is the *absence of a record*, not a * record of an absence - writing it down would mean backfilling every existing patient with a statement * nobody made. **No recorded absence IS "not asked"**, which is also why an imported or historical * record needs no migration. */ export type AbsenceReason = 'asked-declined' | 'asked-unknown' | 'not-applicable'; export const ABSENCE_LABEL: Record = { // The receptionist's wording, not FHIR's. `asked-declined` is a code; "Declined to say" is what a // person reads and immediately knows not to ask again. 'asked-declined': 'Declined to say', 'asked-unknown': 'Does not know', 'not-applicable': 'None', }; /** * The reasons a patient may have no email, in the order the front desk offers them. * * ONE LIST, because there are now three surfaces that let staff record it - the receptionist's register * dialog, the profile card, and the Add Patient form - and each of them had built this same array from * `ABSENCE_LABEL` itself. Three copies of one list is how two front-desk screens come to offer different * options, or the same options in a different order, for the same question. * * The ORDER is part of the answer, not decoration: "None" first is the common case at a desk. * The CODES are the three the server accepts, so a fourth invented at a call site would be refused at * the door rather than silently stored. */ export const EMAIL_ABSENCE_OPTIONS: ReadonlyArray<{ value: AbsenceReason; label: string }> = ( ['not-applicable', 'asked-declined', 'asked-unknown'] as const satisfies readonly AbsenceReason[] ).map((value) => ({ value, label: ABSENCE_LABEL[value] })); /** The demographic fields this covers, named as the UI names them rather than as FHIR spells them. */ export type PatientAbsenceField = | 'phone' | 'email' | 'address' | 'birthDate' | 'maritalStatus' | 'gender' | 'language' | 'emergencyContact'; const isAbsenceReason = (value: unknown): value is AbsenceReason => value === 'asked-declined' || value === 'asked-unknown' || value === 'not-applicable'; type WithExtension = { extension?: Array<{ url?: string; valueCode?: string }> }; /** The reason carried by any element that can hold extensions, or undefined if it carries none. */ const reasonOn = (element: WithExtension | undefined): AbsenceReason | undefined => { const found = element?.extension?.find((ext) => ext.url === DATA_ABSENT_REASON_URL)?.valueCode; return isAbsenceReason(found) ? found : undefined; }; /** * A `telecom` / `contact` entry that names its own system and carries NO value is how an array says "we * asked about this one and there is nothing to record". An entry with a value is a real datum and is never * an absence, however it is extended. */ const telecomAbsence = (patient: Patient, system: 'phone' | 'email'): AbsenceReason | undefined => { const entry = patient.telecom?.find((t) => t.system === system && !t.value) as WithExtension | undefined; return reasonOn(entry); }; /** * THE ONE QUESTION EVERY SURFACE ASKS. * * Returns the recorded reason, or `undefined` for **"nobody has said"** - which is the answer for every * patient in the system today and must stay that way until somebody records otherwise. */ export const absenceOf = ( patient: Patient | undefined, field: PatientAbsenceField, ): AbsenceReason | undefined => { if (!patient) return undefined; switch (field) { case 'phone': return telecomAbsence(patient, 'phone'); case 'email': return telecomAbsence(patient, 'email'); case 'address': return reasonOn(patient.address?.find((a) => !a.line?.length && !a.city && !a.postalCode)); case 'maritalStatus': // A CodeableConcept that HAS a coding is an answer, not an absence - a record carrying both is // contradictory, and the value wins. Reading it the other way would hide real data behind a note. return patient.maritalStatus?.coding?.length ? undefined : reasonOn(patient.maritalStatus); case 'language': { const entry = patient.communication?.find((c) => !c.language?.coding?.length); return reasonOn(entry?.language); } case 'emergencyContact': return reasonOn(patient.contact?.find((c) => !c.name && !c.telecom?.length)); case 'birthDate': // The shadow twin. `_birthDate` is FHIR's, not ours - see the note at the top of this file. return patient.birthDate ? undefined : reasonOn((patient as { _birthDate?: WithExtension })._birthDate); case 'gender': return patient.gender ? undefined : reasonOn((patient as { _gender?: WithExtension })._gender); default: return undefined; } }; /** * WHAT A SURFACE SHOULD PRINT for a field with no value. * * Returns `undefined` when nothing was recorded, and the caller keeps its dash. **This function is the * fence**: a surface that renders `absenceLabelFor(...) ?? '-'` cannot accidentally claim an unasked field * was declined, because there is no code path that produces a label without a recorded reason. */ export const absenceLabelFor = ( patient: Patient | undefined, field: PatientAbsenceField, ): string | undefined => { const reason = absenceOf(patient, field); return reason ? ABSENCE_LABEL[reason] : undefined; }; /** Has anybody recorded anything about this field's absence? Sugar, so surfaces read as prose. */ export const wasAsked = (patient: Patient | undefined, field: PatientAbsenceField): boolean => absenceOf(patient, field) !== undefined; // THE WRITE HALF const absenceExtension = (reason: AbsenceReason) => ({ extension: [{ url: DATA_ABSENT_REASON_URL, valueCode: reason }], }); /** * Put the reason ONTO an element, keeping every other extension it already carries. * * THIS EXISTS BECAUSE THE OBVIOUS SPREAD IS WRONG, and a test caught it rather than a review: * `{...element,...absenceExtension(reason) }` replaces `extension` **wholesale**, so recording an * absence silently deleted every unrelated extension on that element. On `_birthDate` that is somebody * else's data, and it would have gone quietly - nothing reads those extensions on the same screen. */ const withAbsence = (element: T | undefined, reason: AbsenceReason): T => { const base = stripped(element) ?? ({} as T); return { ...base, extension: [...(base.extension ?? []), ...absenceExtension(reason).extension] }; }; /** Drop any data-absent-reason from an element, keeping every other extension it carries. */ const stripped = (element: T | undefined): T | undefined => { if (!element) return undefined; const rest = (element.extension ?? []).filter((ext) => ext.url !== DATA_ABSENT_REASON_URL); const next = { ...element }; if (rest.length > 0) next.extension = rest; else delete next.extension; return next; }; /** * RECORD (or clear) why a demographic field is blank. Mutates a DRAFT, matching how every other field * on the profile card is saved - `savePatientField` re-fetches, deep-clones, mutates, and PUTs. * * **CLEARING IS `undefined`, AND IT REMOVES THE RECORD RATHER THAN WRITING "not asked".** Those are * different: one says nobody has spoken, the other would be us speaking for them. See the note on * `AbsenceReason`. * * **RECORDING AN ABSENCE NEVER DELETES A VALUE.** If the field holds real data this is a no-op - a * receptionist mis-clicking "Declined to say" on a patient's phone number must not remove the number. The * way to remove a value is to clear the value; the way to say why it is missing is this. The read side * already resolves a contradictory record in favour of the value, so a write that created one would be * silently ignored anyway - this makes it impossible instead of merely harmless. */ export const recordAbsence = ( draft: Patient, field: PatientAbsenceField, reason: AbsenceReason | undefined, ): void => { // The guard above, applied once for every field: a present value wins and nothing is written. if (reason !== undefined && hasValueFor(draft, field)) return; switch (field) { case 'phone': case 'email': setTelecomAbsence(draft, field, reason); return; case 'address': draft.address = replaceEmptyEntry(draft.address, reason, (a) => !a.line?.length && !a.city && !a.postalCode); return; case 'emergencyContact': draft.contact = replaceEmptyEntry(draft.contact, reason, (c) => !c.name && !c.telecom?.length); return; case 'maritalStatus': draft.maritalStatus = reason ? withAbsence(draft.maritalStatus, reason) : stripped(draft.maritalStatus); if (!reason && draft.maritalStatus && !draft.maritalStatus.coding?.length && !draft.maritalStatus.extension) { delete draft.maritalStatus; } return; case 'language': { const others = (draft.communication ?? []).filter((c) => c.language?.coding?.length); draft.communication = reason ? [...others, { language: absenceExtension(reason) }] : others; if (draft.communication.length === 0) delete draft.communication; return; } case 'birthDate': setPrimitiveAbsence(draft, '_birthDate', reason); return; case 'gender': setPrimitiveAbsence(draft, '_gender', reason); return; default: return; } }; /** Does the field hold a real answer right now? The write guard, and the read side's tie-break. */ export const hasValueFor = (patient: Patient, field: PatientAbsenceField): boolean => { switch (field) { case 'phone': return Boolean(patient.telecom?.some((t) => t.system === 'phone' && t.value)); case 'email': return Boolean(patient.telecom?.some((t) => t.system === 'email' && t.value)); case 'address': return Boolean(patient.address?.some((a) => a.line?.length || a.city || a.postalCode)); case 'emergencyContact': return Boolean(patient.contact?.some((c) => c.name || c.telecom?.length)); case 'maritalStatus': return Boolean(patient.maritalStatus?.coding?.length); case 'language': return Boolean(patient.communication?.some((c) => c.language?.coding?.length)); case 'birthDate': return Boolean(patient.birthDate); case 'gender': return Boolean(patient.gender); default: return false; } }; const setTelecomAbsence = (draft: Patient, system: 'phone' | 'email', reason: AbsenceReason | undefined): void => { // Keep every OTHER telecom entry untouched - a patient with two phone numbers and no email must not // lose a number because somebody recorded that they have no email. const others = (draft.telecom ?? []).filter((t) => !(t.system === system && !t.value)); draft.telecom = reason ? [...others, { system, ...absenceExtension(reason) }] : others; if (draft.telecom.length === 0) delete draft.telecom; }; const replaceEmptyEntry = ( list: T[] | undefined, reason: AbsenceReason | undefined, isEmpty: (entry: T) => boolean, ): T[] | undefined => { const others = (list ?? []).filter((entry) => !isEmpty(entry)); const next = reason ? [...others, absenceExtension(reason) as unknown as T] : others; return next.length > 0 ? next : undefined; }; const setPrimitiveAbsence = ( draft: Patient, twin: '_birthDate' | '_gender', reason: AbsenceReason | undefined, ): void => { const holder = draft as unknown as Record; if (reason) { holder[twin] = withAbsence(holder[twin], reason); return; } const rest = stripped(holder[twin]); if (rest && rest.extension) holder[twin] = rest; else delete holder[twin]; };