import type { QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4'; import { formatDate, formatDateTime } from './dateUtils'; /** * - reading back what a patient sent, as words rather than as controls. * * WHY THIS IS SEPARATE FROM THE COMPONENT. Turning a FHIR answer into a sentence is where this can be * wrong in ways nobody sees: a boolean rendered as "true", a coding rendered as its code, a date * rendered as an ISO string, an unanswered question rendered as the word "undefined". Each of those is a * clinical read surface telling the patient something slightly false about what they said. Keeping the * decision pure means every one of them can be exercised without a questionnaire, a response, a form * provider or a page. * * WHAT IT DELIBERATELY DOES NOT DO. It never invents a value for a question that was not answered - it * returns undefined and lets the caller decide, because "not answered" and "answered with nothing" are * different facts about a record and only the caller knows whether the question should be shown at all. */ type Answer = NonNullable[number]; /** * One answer, as the patient would read it. * * ORDER MATTERS ONLY WHERE THE SHAPES OVERLAP. A FHIR answer carries exactly one value[x], so these are * mutually exclusive in practice; the sequence is chosen so the richest rendering is reached first for * the two that can carry a display as well as a raw value (Coding, Quantity). */ export const answerToText = (answer: Answer): string | undefined => { if (answer.valueCoding) { // The DISPLAY, never the code. A code shown to a patient is not an answer, it is an identifier // for one - and this is the single most likely way this surface says something meaningless. return answer.valueCoding.display ?? answer.valueCoding.code; } if (answer.valueQuantity) { const { value, unit } = answer.valueQuantity; if (value === undefined) return undefined; return unit ? `${value} ${unit}` : String(value); } // `typeof` rather than truthiness: `false` and `0` are answers, and both are falsy. A patient who // answered "No" must not read the question as unanswered. if (typeof answer.valueBoolean === 'boolean') return answer.valueBoolean ? 'Yes' : 'No'; if (typeof answer.valueInteger === 'number') return String(answer.valueInteger); if (typeof answer.valueDecimal === 'number') return String(answer.valueDecimal); if (answer.valueDate) return formatDate(answer.valueDate); if (answer.valueDateTime) return formatDateTime(answer.valueDateTime); if (answer.valueTime) return answer.valueTime; if (answer.valueString) return answer.valueString; if (answer.valueUri) return answer.valueUri; // An attachment reads as its title. The file itself is shown by the surface that owns it; a title is // what the patient recognises as the thing they attached. if (answer.valueAttachment) { return answer.valueAttachment.title ?? answer.valueAttachment.url; } if (answer.valueReference) { return answer.valueReference.display ?? answer.valueReference.reference; } return undefined; }; /** Every answer to one question, joined. Repeats are one question answered several times, not several. */ export const answersToText = (item: QuestionnaireResponseItem): string | undefined => { const parts = (item.answer ?? []) .map(answerToText) .filter((text): text is string => text !== undefined && text !== ''); return parts.length ? parts.join(', ') : undefined; }; export interface AnsweredQuestion { linkId: string; question: string; answer: string; } /** * The response, flattened to the questions that were actually answered, in the order they were asked. * * NESTED ITEMS ARE THE POINT. A questionnaire of any size groups its questions, and the answers live on * the leaves - so a reader that only walked the top level would show a patient an empty record of a form * they had filled in completely. Both shapes are walked: an item may carry answers AND children, and a * child may hang off the item or off one of its answers. */ export const answeredQuestions = (response: QuestionnaireResponse | undefined): AnsweredQuestion[] => { const out: AnsweredQuestion[] = []; const walk = (items: QuestionnaireResponseItem[] | undefined) => { for (const item of items ?? []) { const answer = answersToText(item); // An unanswered question is omitted rather than shown blank. The patient is reading what // they SENT; a list padded with empty rows misrepresents that, and a question they skipped // is not a thing they said. if (answer) { out.push({ linkId: item.linkId, question: item.text ?? item.linkId, answer }); } walk(item.item); for (const nested of item.answer ?? []) walk(nested.item); } }; walk(response?.item); return out; };