import { RECORD_FIELD_CATALOGUE } from './recordFieldCatalogue.generated'; import type { ProposalKind } from './recordProposals'; /** * The search that resolves to THINGS rather than to kinds. * * `mockups/practitioner/encounter.html`, in its own words: *"The list holds things, not kinds. A * clinician does not search for 'Prescription', they search for metformin - the kind is decided by what * they pick, which is Epic's whole answer to a chart too large to menu. The four kinds stay underneath * for the thing that is not on anybody's list yet."* * * SO THE KIND IS AN OUTCOME, NOT A QUESTION. Every row carries the kind it would create, and picking * one opens that kind's surface already holding the coding that was picked. Asking "prescription or * diagnosis?" first is the menu this surface exists to escape. * * AND THE STRENGTH IS THE DECISION, which is why matches are not collapsed to one row per drug. The * deck: *"A clinician typing 'metfor' has already decided the drug and is choosing the dose, so the list * is doses rather than one row per drug with a picker behind it."* */ /** One thing a clinician can add, as it appears in the search. */ export interface RecordThing { /** Unique within a result list. The coding when there is one, else the label. */ id: string; label: string; /** What picking it creates. Shown on the row, because the row is the only place it is ever said. */ kind: ProposalKind; code?: string; system?: string; } /** * THE FIELD THE PICKED THING GOES INTO, per kind. * * Stated rather than derived, because "the field that IS the thing" is a judgement about meaning and * every mechanical rule for it is a coincidence: it is not simply the first required field, nor the * first coded one - a diagnosis marks its category required too, and a prescription marks its status. * * BUT IT IS NOT UNCHECKED. A test asserts every name here exists in the derived catalogue as a * REQUIRED CODEABLE CONCEPT, so a field renamed in a form takes that test red instead of quietly seeding * nothing - which on screen is indistinguishable from a search that found nothing worth carrying over. */ export const PRIMARY_FIELD: Partial> = { medication: 'medicationCodeableConcept', condition: 'code', }; /** The kinds whose things the search can actually resolve, in the order the drawing lists them. */ export const SEARCHABLE_THING_KINDS: readonly ProposalKind[] = ['medication', 'condition', 'labOrder']; /** * What a picked thing prefills. * * A VALUE-SET OBJECT, NOT A STRING, and that is the whole point of picking from a search: the kind's own * transform reads `.code` and `.system` off it, so the item is filed with a real coding. Seeding the * display alone would put the clinician back where free text left them - words that look coded. */ export const seedForThing = (thing: RecordThing): Record => { const field = PRIMARY_FIELD[thing.kind]; if (!field) return {}; return { [field]: { display: thing.label, ...(thing.code ? { code: thing.code } : {}), ...(thing.system ? { system: thing.system } : {}), }, }; }; /** * How many typed matches are shown. * * The deck's *"offer six with two wrong and a clinician stops reading it"* is about the UNPROMPTED short * list, where every row is a guess. These rows were asked for, and four strengths of one drug is the * drawn case - so the cap is generous enough to hold a drug's whole ladder and short enough to scan * without the box becoming the flat catalogue it replaces. */ export const MAX_THING_MATCHES = 8; const startsWith = (label: string, term: string) => label.toLowerCase().startsWith(term.toLowerCase()); /** * Merge what each kind's lookup returned into one ranked list. * * RANKED, NOT INTERLEAVED. Giving each kind an equal share reads as fairness and is wrong here: typing * "metfor" should return metformin's strengths, and a round robin would drop three of them to make room * for a diagnosis that merely contains the letters. What the clinician typed decides the order. */ export const mergeThingMatches = ( found: readonly RecordThing[], term: string, cap: number = MAX_THING_MATCHES, ): RecordThing[] => { const seen = new Set(); const unique = found.filter((thing) => { // The same code can be reachable through two value sets. A list showing it twice reads as two // different things and makes the clinician choose between identical rows. const key = `${thing.kind}:${thing.id}`; if (seen.has(key)) return false; seen.add(key); return true; }); const trimmed = term.trim(); return unique .map((thing, index) => ({ thing, index })) .sort((a, b) => { const aStarts = startsWith(a.thing.label, trimmed); const bStarts = startsWith(b.thing.label, trimmed); if (aStarts !== bStarts) return aStarts ? -1 : 1; // Shorter first among equals: "Metformin 500 mg tablet" before "Metformin 500 mg tablet, // film coated, extended release". The plainer name is the one being looked for. if (a.thing.label.length !== b.thing.label.length) { return a.thing.label.length - b.thing.label.length; } // A STABLE TIE-BREAK, so the list does not reshuffle between two identical searches - a row // that moves while somebody is reaching for it is worse than a row in the wrong place. return a.index - b.index; }) .slice(0, cap) .map((entry) => entry.thing); }; /** * The typed part of a label, split out so it can be shown in place. * * Drawn as `Metfor|min 500 mg tablet` - the match emphasised where it sits rather than the row being * annotated with why it matched. Returns null when the term is not a prefix, because emphasising the * middle of a word is noise rather than help. */ export const splitOnPrefix = (label: string, term: string): { matched: string; rest: string } | null => { const trimmed = term.trim(); if (!trimmed || !startsWith(label, trimmed)) return null; return { matched: label.slice(0, trimmed.length), rest: label.slice(trimmed.length) }; }; /** True when the catalogue holds this field for this kind as a required codeable concept. */ export const isPrimaryFieldSound = (kind: ProposalKind, field: string): boolean => { const fields = RECORD_FIELD_CATALOGUE[kind as keyof typeof RECORD_FIELD_CATALOGUE]; const found = fields?.find((entry) => entry.name === field); return Boolean(found && found.shape === 'codeableConcept' && found.required); };