import { isFileableDate, isFileableTime } from './dateUtils'; /** * The form built on the fly when a practitioner adds a proposed item to the record. * * SPENCER, 2026-09-03, specifying it directly: *"You build these forms on the fly when the practitioner * clicks the button to add to record. You load the fields that get pre-filled from the notes first at the * top. Under the pre-filled fields you load the required fields. Then under required field you load the * rest of them."* * * THE BANDS ARE DERIVED, NOT DECLARED, and that is what makes this a composition rather than a per- * resource design exercise. What the note filled is data about THIS item; what is required is already * marked on the field; everything else is the remainder by subtraction. No catalogue of "which fields * belong in which band" exists or needs to. * * WHY THIS ORDER AND NOT THE FORM'S OWN. The clinician is CONFIRMING an extraction, not filling a form. * What the note supplied goes first because that is what they are checking; what is still owed goes next * because it is the only thing they must act on; and the long tail goes last because on most items nobody * will open it. A form in its designed order buries all three of those among each other. */ /** One field of a resource's form, as the composed overlay needs to know it. */ export interface RecordField { /** The path the value lives at, matching what the form's own control binds to. */ name: string; /** What the clinician reads. Never the FHIR path. */ label: string; /** Marked on the field itself, which is where the existing forms already carry it. */ required?: boolean; /** * What the value becomes in the resource, read from the control that renders it. * * `unfileable` is the honest one: a Reference, a Period or a Range cannot be built from a line of * typed text, so the field is shown - a clinician may want to read what the note said about it - * and it is not sent. `stamped` and `choiceSelector` are not shown at all; see `fieldsForKind`. */ shape: 'codeableConcept' | 'code' | 'quantity' | 'annotation' | 'text' | 'dateTime' | 'unfileable' | 'stamped' | 'choiceSelector' | 'unsearchable' | 'choice'; /** * The answers a `choice` offers, each the value it files. Unanswered until the clinician picks one: a * `choice` is never filled from the note (see `noteMayFill`). */ choices?: readonly { value: boolean; label: string }[]; /** Shown, owed and sent only while another field holds this value: `reportOrigin` only for a secondary report. */ onlyWhen?: { name: string; equals: unknown }; /** * The resource holds a LIST here, not a single value. * * Carried because the transforms pass what they are given straight through: a bare string on a list * field files `"food"` where FHIR requires `["food"]`, which is invalid and silent. */ list?: boolean; /** * Which value set this coded field is searched against. * * A coded field is a SEARCH, not a text box - Spencer, 2026-09-04: "it needs to be coded. We should * have a search select option where they can select the coded prescription." Present on every * `codeableConcept` and `code` field, because a coded field with nothing to search is a text box * wearing a different name. */ valueSet?: string; /** * One ARM of a FHIR choice, required only when that arm is the one in use. * * An observation's value is a number OR a code; a procedure's `performed` is a date OR a * description. The overlay cannot know which arm applies, so it offers these and never demands * them - demanding one would lock Save on every item that took the other. */ conditional?: boolean; /** * A record's own date, which FHIR keeps at the precision given: a day the note gave stays that day, or a date and * time names one moment. Without it a `dateTime` field is a moment (a reading's time) and a day alone is owed. */ dayAllowed?: boolean; } export type RecordFormBand = 'from-the-note' | 'still-needed' | 'more-information'; export interface ComposedField { field: RecordField; band: RecordFormBand; /** Present only for a field the note supplied - it is what the clinician is confirming. */ value?: unknown; } /** * A value counts as supplied when it is actually there. * * AN EMPTY STRING IS NOT A VALUE, and treating it as one is how a field arrives in the top band looking * answered while holding nothing - the worst of the three outcomes, because it is the band the clinician * skims rather than fills. */ /** * Read a field's value, whether it is held flat or nested. * * BOTH SPELLINGS ARE REAL AND EACH HAS A REASON. The extraction returns a FLAT map keyed by the field's * full path - `{"dispenseRequest.quantity": "30"}` - because it has no form to write into. The overlay's * own form is `react-hook-form`, which treats a dot as NESTING, so the same field lives at * `{dispenseRequest: {quantity}}` once a clinician types in it. * * WALKED AND FOUND, 2026-09-04: reading only the flat key meant a dotted field NEVER left "Still * needed". A prescription's quantity and refills could be typed in full and Save stayed disabled, while * every flat field beside them moved up correctly - so the surface looked like it worked and refused to * file exactly the two fields that were added to make a prescription safe. */ export const readValue = (values: Record, name: string): unknown => { // The nested value is the one the form's control writes, so it wins: a flat key left beside it is the note's // first guess, and reading it first would file a value the clinician had removed. let cursor: unknown = values; for (const part of name.split('.')) { if (cursor === null || typeof cursor !== 'object') { cursor = undefined; break; } cursor = (cursor as Record)[part]; } if (cursor !== undefined || !name.includes('.')) return cursor; return values[name]; }; /** Whether the note may fill this field. A `choice` is an assertion only the clinician makes. */ export const noteMayFill = (field: RecordField): boolean => field.shape !== 'choice'; /** Whether a field applies to these values: always, unless it is shown only while another field holds a value. */ export const applies = (field: RecordField, values: Record): boolean => !field.onlyWhen || readValue(values, field.onlyWhen.name) === field.onlyWhen.equals; /** A date field holding something it cannot file: not absent, and not a real day or one moment. */ const unresolvedDate = (field: RecordField, value: unknown): boolean => field.shape === 'dateTime' && typeof value === 'string' && value.trim() !== '' && !supplied(field, value); const supplied = (field: RecordField, value: unknown): boolean => { if (value === undefined || value === null) return false; // A choice is answered by one of its own answers, false as much as true, and by nothing else. if (field.shape === 'choice') return (field.choices ?? []).some((choice) => choice.value === value); // A coded list is answered when it holds a code: the note's uncoded words alone are a suggestion, as below. if (Array.isArray(value) && (field.shape === 'code' || field.shape === 'codeableConcept')) { return value.some((entry) => typeof (entry as { code?: unknown })?.code === 'string' && (entry as { code: string }).code.trim() !== ''); } // A time is answered only when it names one moment the box can show: else it files what nobody saw. if (field.shape === 'dateTime') return field.dayAllowed ? isFileableDate(value) : isFileableTime(value); if (typeof value === 'string') return value.trim() !== ''; if (Array.isArray(value)) return value.length > 0; /* A CODED FIELD WITH NO CODE IS NOT ANSWERED, and this rule has to live in the same place as the one that decides what gets sent, or the two disagree about the same field. Spencer, 2026-09-04, on exactly this flow: *"No, it needs to be coded. We should have a search select option where they can select the coded prescription."* It covers a diagnosis as much as a prescription - the question he answered named both - and the reader tree carries it as a product rule: *"Coded fields file only through the terminology search; free text sits beside a code, never instead of it."* They did. What the extraction seeds on a coded field is display-only - it read words, not a terminology - so `status` arrived looking like `{ display: 'active' }`. The banding called that supplied and put it in the first band under "From your note", Save unlocked, and the shaping correctly refused to send words as a FHIR code. The result was a prescription posted with no status at all and refused by the server, over a field the clinician was never asked to fill. A `code` is a bare token on the wire. Anything without one is a suggestion, not an answer. */ /* A MEASUREMENT IS ANSWERED WHEN IT HAS A NUMBER, and not before. Its control binds an object - a value and a unit - so the plain object test below would call an untouched row supplied and band it under "From your note", which is the band a clinician skims rather than fills. The unit alone is not an answer either: a row holding millimetres of mercury and no reading says which scale nothing was measured on. */ if (field.shape === 'quantity' && typeof value === 'object') { const held = (value as { value?: unknown }).value; return Number.isFinite(typeof held === 'string' ? Number(held.trim() === '' ? NaN : held) : held); } if ((field.shape === 'code' || field.shape === 'codeableConcept') && typeof value === 'object') { const code = (value as { code?: unknown }).code; return typeof code === 'string' && code.trim() !== ''; } return true; }; /** * Order the fields into the three bands. * * A PREFILLED FIELD STAYS IN THE FIRST BAND EVEN WHEN IT IS REQUIRED, because the bands answer different * questions: the first is *"is this right?"* and the second is *"what do you still owe?"*. Repeating a * field in both would ask for something already on screen, and moving it to the second would hide a value * the note supplied among the things that are missing. */ export const composeRecordForm = ( fields: readonly RecordField[], prefilled: Record, ): ComposedField[] => { const banded = fields.filter((field) => applies(field, prefilled)).map((field): ComposedField => { const value = readValue(prefilled, field.name); if (supplied(field, value)) return { field, band: 'from-the-note', value }; // A date holding something that is not one moment (a time the clocks skip or repeat here) is owed even on an // optional arm: left optional it would be dropped on save, and the date with it. if (field.required || unresolvedDate(field, value)) return { field, band: 'still-needed' }; return { field, band: 'more-information' }; }); const order: Record = { 'from-the-note': 0, 'still-needed': 1, 'more-information': 2, }; // A STABLE SORT, so within a band the fields keep the order the form declares them in. Re-ordering // inside a band would scramble groups that read as a unit - a dosage and its frequency, a lot number // and its site. return banded .map((composed, index) => ({ composed, index })) .sort((a, b) => (order[a.composed.band] - order[b.composed.band]) || (a.index - b.index)) .map(({ composed }) => composed); }; /** The fields in one band, for rendering it under its heading. */ export const fieldsInBand = (composed: readonly ComposedField[], band: RecordFormBand): ComposedField[] => composed.filter((entry) => entry.band === band); /** * Can this item be filed yet? * * Spencer, refusing file-and-flag: *"They can't finish adding the item while there's still required * fields missing, that's the point of required fields."* So this is the Save control's own condition, * not a warning shown beside it. */ export const missingRequired = (composed: readonly ComposedField[]): string[] => fieldsInBand(composed, 'still-needed').map((entry) => entry.field.label);