// - ONE vocabulary for a FHIR status, shared by the patient portal and the staff chart. // // THE DEFECT IT REPLACES, in the patient's own words-on-screen: their visit history read // `Encounter [finished]` - `Encounter [in progress] (From Aug 2, 2026)` - `[finished] (Jul 18 - Jul 19)` // - the FHIR code in brackets, with no practitioner, no reason and no location, several rows carrying no // title at all. A patient cannot answer *"when did I come in about my knee?"* from that. It is a health // record rendering itself as debug output. // // THE STANDARD THIS IS HELD TO is already in the product, and the card names it: the profile-incomplete // copy was recorded during the walk as genuinely good **because it explains why a thing matters in the // reader's terms rather than naming the field**. So the fix is not "title-case the enum" - it is "say what // happened". // // WHY ONE MAPPING AND NOT A STRING PER SURFACE. Measured 2026-08-12: **24 service files** build a // display string with the identical `` ` [${x.status.replace(/-/g, ' ')}]` `` expression. Fixed one at a // time, the product ends up with three different words for the same state - which is the card's own // warning, and the reason this module exists before any call site is touched. // // DO NOT add a surface-specific override here. If the patient and the clinician genuinely need // different words for one state, that is a product decision and belongs on a card, not in a second map. /** * Human labels for the FHIR status codes this product actually renders. * * Keyed by the RAW code, including the hyphenated forms, so a caller never has to normalise first - the * normalising was itself part of the defect (`in-progress` `in progress` shown in brackets). * * Deliberately NOT exhaustive over every FHIR value set. A code we have no words for returns * `undefined` from `statusLabel` so the caller can decide, rather than getting a machine-shaped fallback * that looks deliberate. See `statusLabel`'s contract. */ const STATUS_LABELS: Readonly> = { // Encounter planned: 'Scheduled', arrived: 'Arrived', triaged: 'Triaged', 'in-progress': 'In progress', onleave: 'On leave', finished: 'Completed', // Shared across many resource types active: 'Active', completed: 'Completed', cancelled: 'Cancelled', 'entered-in-error': 'Entered in error', unknown: 'Unknown', draft: 'Draft', 'on-hold': 'On hold', stopped: 'Stopped', revoked: 'Revoked', inactive: 'Inactive', suspended: 'Suspended', // Request-shaped proposed: 'Proposed', pending: 'Pending', booked: 'Booked', fulfilled: 'Completed', noshow: 'No show', 'checked-in': 'Checked in', waitlist: 'Waitlisted', // Added by the 23-service sweep, and the reason is worth keeping // THESE WERE NOT OPTIONAL POLISH. `statusLabel` returning `undefined` makes a caller OMIT the // status, so sweeping the services onto this map without them would have deleted real clinical // information from the screen - an Observation losing "Preliminary" vs "Final", a MedicationStatement // losing "Not taken", a Task losing "Rejected". Silently, and looking tidier for it. // The map has to cover what the swept surfaces can actually emit BEFORE they are swept. // Observation - DiagnosticReport - RiskAssessment - DetectedIssue registered: 'Registered', preliminary: 'Preliminary', final: 'Final', amended: 'Amended', corrected: 'Corrected', mitigated: 'Mitigated', // DocumentReference current: 'Current', superseded: 'Superseded', // Medication* - administration, dispense, statement preparation: 'In preparation', declined: 'Declined', intended: 'Intended', 'not-taken': 'Not taken', 'not-done': 'Not done', // Task requested: 'Requested', received: 'Received', accepted: 'Accepted', rejected: 'Rejected', ready: 'Ready', // "Unable to complete", not "Failed" - Spencer ruled 2026-08-14, and it is the ONE word adopted out // of the four `TaskService` had been keeping in a competing map. It survived where the other three did // not because it is not merely gentler, it is MORE PRECISE for every reader: "Failed" says a thing is // over, "Unable to complete" says it stopped short. That reads correctly to a clinician about a lab // draw and to a patient about something they were asked to do - which is the test a shared word has to // pass. See the card-248 entry for why "Withdrawn"/"Declined"/"Removed" were rejected on ACCURACY. failed: 'Unable to complete', // EpisodeOfCare spells it WITHOUT the hyphen. Not a typo and not a duplicate - `on-hold` above is // the spelling every other resource uses, and a map keyed by the raw code has to carry both or one // whole resource type silently loses its status. onhold: 'On hold', }; /** * The human label for a FHIR status code, or `undefined` when we have no words for it. * * `undefined` RATHER THAN THE RAW CODE, and the distinction is the whole point: returning the code * lets an unmapped status reach the screen looking like a deliberate label - which is the defect this * module exists to remove, reintroduced through the fallback. A caller that gets `undefined` must decide * whether to omit the status entirely (usually right) or say something explicit. * is the same instinct: do not dress up * "we don't know" as an answer. */ export function statusLabel(status: string | undefined): string | undefined { if (!status) return undefined; return STATUS_LABELS[status]; } /** Exported for tests and for a coverage guard; not for building UI strings. */ export const KNOWN_STATUS_CODES: ReadonlyArray = Object.keys(STATUS_LABELS); /** * The human label for a status carried as a **CodeableConcept** rather than a raw code. * * WHY THIS IS NOT JUST `statusLabel(coding[0].code)`, and getting it wrong DELETES CLINICAL FACTS. * The clinical value sets these surfaces use are wider than this map on purpose: Condition emits * `remission` - `relapse` - `recurrence` - `resolved`, verification emits `refuted` - `provisional` - * `differential`, and an immunisation forecast emits `overdue` - `immune` - `contraindicated`. **None of * those is in `STATUS_LABELS`**, so routing them through `statusLabel` alone returns `undefined`, the * caller omits the status, and *"Asthma - Remission"* silently becomes *"Asthma"*. That is the same * vanishing the coverage guard was built to stop, arriving from the other direction. * * SO THE ORDER IS DELIBERATE: our vocabulary FIRST - so the states we do share (`active`, `inactive`, * `entered-in-error`) read identically everywhere - then the terminology server's own `display`, which is * already human text written by the authority that owns the value set. Second-guessing that display would * be this module inventing clinical words it has no standing to invent. * * The raw CODE is never a fallback. `Asthma - remission` is the defect wearing different punctuation. * Returning `undefined` and omitting is the honest end of the chain. * * The thirteen unmapped clinical codes above are a real gap, but adding them means choosing words AND a * D5 tone for each on value sets nobody has validated - that belongs on a card, not in this slice. */ export function codeableStatusLabel( concept: { coding?: Array<{ code?: string; display?: string }> } | undefined, ): string | undefined { const coding = concept?.coding?.[0]; if (!coding) return undefined; return statusLabel(coding.code) ?? coding.display ?? undefined; } // Status TONE (design decision D5) // // D5: *"Status is encoded TWICE - colour and shape. Four states, and resist a fifth. Colour alone fails // for ~8% of men and this is a clinical product."* The four are `live` (filled circle) - `attention` // (triangle) - `done` (hollow ring) - `cancelled` (bar). // // TONE LIVES HERE, BESIDE THE LABEL, FOR THE SAME REASON THE PRIORITIES DO. "What do we say for this // code" and "how loudly do we say it" are one decision made twice; in two modules they drift, and the // drift shows up as a status the patient portal calls Cancelled in red while the staff chart draws it as // merely finished. // // `undefined` IS A REAL ANSWER HERE, and resisting the fifth state is what makes it necessary. A // lifecycle code like `inactive` or `unknown` is not progress - it is neither live, nor finished, nor // cancelled, nor asking anybody for anything. Forcing it into the nearest bucket would draw a hollow // "completed" ring next to a record that completed nothing. Callers render the LABEL with no shape. /** The four states of D5. Deliberately not extensible - a fifth belongs on a card, not in this union. */ export type StatusTone = 'live' | 'attention' | 'done' | 'cancelled'; const STATUS_TONES: Readonly> = { // live - underway or genuinely upcoming; nothing is being asked of the reader planned: 'live', arrived: 'live', triaged: 'live', 'in-progress': 'live', onleave: 'live', active: 'live', booked: 'live', 'checked-in': 'live', waitlist: 'live', // attention - a human has to do something, or something stopped short of finishing. // `proposed` and `pending` sit HERE rather than in `live` on purpose: both mean "waiting on a // decision somebody owes", and drawing them as quietly in-flight is how a consent request or an // unaccepted invitation goes unnoticed for a week. proposed: 'attention', pending: 'attention', draft: 'attention', 'on-hold': 'attention', stopped: 'attention', suspended: 'attention', noshow: 'attention', // done - finished normally, and unremarkable finished: 'done', completed: 'done', fulfilled: 'done', // cancelled - terminal and void. `entered-in-error` belongs here rather than in `attention`: the // record was retracted, so it is not asking for action, it is asking to be disregarded. cancelled: 'cancelled', revoked: 'cancelled', 'entered-in-error': 'cancelled', // Tones for the codes the 23-service sweep brought in // live - the result or request is real but not yet settled registered: 'live', preliminary: 'live', current: 'live', preparation: 'live', intended: 'live', requested: 'live', received: 'live', accepted: 'live', ready: 'live', // done - settled normally. `amended` and `corrected` are FINISHED states, not warnings: a corrected // lab result is the good outcome of noticing an error, and drawing it as attention would train // clinicians to read routine corrections as problems. final: 'done', amended: 'done', corrected: 'done', mitigated: 'done', superseded: 'done', // attention - abnormal, and somebody should see it. `not-taken` / `not-done` / `declined` / // `rejected` / `failed` are the clinically interesting halt states; they are exactly the ones a // colour-only encoding loses, which is why D5 gives them a triangle. onhold: 'attention', declined: 'attention', 'not-taken': 'attention', 'not-done': 'attention', rejected: 'attention', failed: 'attention', }; /** * The D5 tone for a FHIR status code, or `undefined` when the code is not a progress state at all. * * Same contract and the same reasoning as `statusLabel`: never invent an answer. A caller that gets * `undefined` should render the label on its own rather than pick a shape. */ export function statusTone(status: string | undefined): StatusTone | undefined { if (!status) return undefined; return STATUS_TONES[status]; } // Request priority ( / finding B8) // // The patient's Tasks card rendered `Priority: ROUTINE` - the FHIR code, shouted. Same habit as the // bracketed status above, same answer: one mapping, and the product's voice rather than the code's. // // These live beside the statuses on purpose. They are a different FHIR element but the SAME decision - // "what do we say instead of the code" - and splitting them into two modules is how a codebase ends up // with two conventions for one question. const PRIORITY_LABELS: Readonly> = { routine: 'Routine', urgent: 'Urgent', asap: 'As soon as possible', stat: 'Immediately', }; /** * The human label for a FHIR request priority, or `undefined` when we have no words for it. * * Same contract as `statusLabel`, for the same reason: an unmapped code must not reach the screen * wearing the shape of a deliberate label. */ export function priorityLabel(priority: string | undefined): string | undefined { if (!priority) return undefined; return PRIORITY_LABELS[priority]; } /** Exported for tests; not for building UI strings. */ export const KNOWN_PRIORITY_CODES: ReadonlyArray = Object.keys(PRIORITY_LABELS);