import type { FieldChange } from './diffResourceVersions'; // - turn a changed path on a Patient into the sentence a patient would say. // // Spencer's ask, verbatim: clicking a change should read "I changed my emergency contact Spouse One's // phone number and added an email address for them" - not `contact[0].telecom[1].value`. // // THE LABELLING IS CONTEXTUAL, and that is the whole difficulty. `contact[0]` means nothing until // you look up WHO contact 0 is, so this takes the resource alongside the change. A purely static // pathlabel map cannot express "Spouse One's phone number", which is precisely the sentence that // makes the row useful. // // SCOPE (option A, Spencer 2026-07-30): deep for Patient first - it is what a patient edits about // themselves most often, and it is the case that prompted this. Other resource types degrade through // the fallback below until they earn their own module. // // THE FALLBACK IS LOAD-BEARING. An unmapped path degrades to plain language and NEVER to the path // itself: a raw path on a patient's screen is the same defect as a raw FHIR type name, not a lesser // version of the fix. A test asserts no path fragment can escape. const TELECOM_WORDS: Readonly> = { phone: 'phone number', email: 'email address', fax: 'fax number', sms: 'text number', url: 'website', }; const FIELD_WORDS: Readonly> = { birthDate: 'date of birth', gender: 'gender', maritalStatus: 'marital status', deceasedBoolean: 'deceased status', communication: 'preferred language', }; const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; /** `contact[0].telecom[1].value` ['contact', 0, 'telecom', 1, 'value'] */ function parsePath(path: string): (string | number)[] { const parts: (string | number)[] = []; for (const segment of path.split('.')) { const match = segment.match(/^([^[]+)((\[\d+\])*)$/); if (!match) { parts.push(segment); continue; } parts.push(match[1]); for (const index of match[2].match(/\d+/g) ?? []) parts.push(Number(index)); } return parts; } function valueAt(resource: unknown, parts: (string | number)[]): unknown { let cursor: unknown = resource; for (const part of parts) { if (!isObject(cursor)) return undefined; cursor = (cursor as Record)[part]; } return cursor; } /** A person's name from a FHIR HumanName, preferring the stored text. */ function humanName(name: unknown): string | undefined { if (!isObject(name)) return undefined; const text = typeof name.text === 'string' ? name.text.trim() : ''; if (text) return text.replace(/\s+/g, ' '); const given = Array.isArray(name.given) ? name.given.filter((g) => typeof g === 'string') : []; const family = typeof name.family === 'string' ? name.family : ''; const joined = [...given, family].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); return joined || undefined; } /** * "phone number" / "email address" for a telecom entry, read from its own `system`. * * The path can end EITHER at a leaf (`telecom[1].value`, an edit) or at the entry itself * (`telecom[1]`, a whole new entry - the differ reports a new subtree once rather than per leaf). Both * have to resolve to the same word, or adding an email would read as "added contact details". */ function telecomWord(resource: unknown, parts: (string | number)[]): string { const entryPath = typeof parts[parts.length - 1] === 'number' ? parts : parts.slice(0, -1); const entry = valueAt(resource, entryPath); const system = isObject(entry) && typeof entry.system === 'string' ? entry.system : undefined; return (system && TELECOM_WORDS[system]) ?? 'contact details'; } const verb = (kind: FieldChange['kind']): string => kind === 'added' ? 'Added' : kind === 'removed' ? 'Removed' : 'Changed'; /** * A sentence describing one changed field on a Patient. * * Never throws and never emits a path: an unmapped field yields a truthful generic phrase. */ export function describePatientChange(changed: FieldChange, resource: unknown): string { const parts = parsePath(changed.path); const [root] = parts; const action = verb(changed.kind); // An emergency contact - identified by NAME, which is the point. if (root === 'contact') { const contactName = humanName(valueAt(resource, [parts[0], parts[1], 'name'])); const who = contactName ? `${contactName}'s` : 'your emergency contact’s'; const owner = contactName ? `emergency contact ${who}` : who; if (parts.includes('telecom')) { return `${action} ${owner} ${telecomWord(resource, parts)}`; } if (parts.includes('name')) { return `${action} ${owner} name`; } if (parts.includes('relationship')) { return `${action} ${owner} relationship to you`; } if (parts.includes('address')) { return `${action} ${owner} address`; } return `${action} ${owner} details`; } // The patient's own contact details, as distinct from a contact person's. if (root === 'telecom') { return `${action} your ${telecomWord(resource, parts)}`; } if (root === 'address') return `${action} your address`; if (root === 'name') return `${action} your name`; if (typeof root === 'string' && FIELD_WORDS[root]) { return `${action} your ${FIELD_WORDS[root]}`; } // Unmapped. True, readable, and free of any path fragment. return `${action} details on your profile`; }