diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a2b8b9ad --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.storybook/public/codify/**/*.mcdx filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index 68f63b90..49b13401 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ playwright-results/ test-results/*.swp TODO.local.md RESTORE-OZWELL.local.md +# MedicalCodify shards (.storybook/public/codify) are committed via git-lfs; +# the extraction TSV + sqlite db live (gitignored) in packages/codify/data/ diff --git a/.gitmodules b/.gitmodules index 20035a00..e2c2a4cc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "packages/ychart"] path = packages/ychart url = https://github.com/mieweb/ychart.git +[submodule "packages/codify"] + path = packages/codify + url = https://github.com/mieweb/codify.git diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 06aa7d8e..cc8b5a66 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -248,6 +248,7 @@ const preview: Preview = { brand: 'bluehive', theme: 'light', density: 'standard', + locale: 'en', }, globalTypes: { brand: { @@ -292,6 +293,18 @@ const preview: Preview = { dynamicTitle: true, }, }, + locale: { + name: 'Language', + description: 'Locale for locale-aware components (e.g. CodeLookup shards)', + toolbar: { + icon: 'globe', + items: [ + { value: 'en', title: 'πŸ‡ΊπŸ‡Έ English' }, + { value: 'es', title: 'πŸ‡ͺπŸ‡Έ EspaΓ±ol (sample)' }, + ], + dynamicTitle: true, + }, + }, }, parameters: { a11y: { diff --git a/.storybook/public/codify/en/condition.mcdx b/.storybook/public/codify/en/condition.mcdx new file mode 100644 index 00000000..4aaf8062 --- /dev/null +++ b/.storybook/public/codify/en/condition.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:75b7d9bcd225915d7cb4a13aca77ef3d883a7aee28f7f34721052ba51b9b8193 +size 14348816 diff --git a/.storybook/public/codify/en/lab.mcdx b/.storybook/public/codify/en/lab.mcdx new file mode 100644 index 00000000..6ce46271 --- /dev/null +++ b/.storybook/public/codify/en/lab.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fb4f4b30f77ce52c2996498216c6790242ac2fc06b93149abf5fd6560b24694 +size 21644204 diff --git a/.storybook/public/codify/en/manifest.json b/.storybook/public/codify/en/manifest.json new file mode 100644 index 00000000..abec75e5 --- /dev/null +++ b/.storybook/public/codify/en/manifest.json @@ -0,0 +1,42 @@ +{ + "version": 2, + "locale": "en", + "builtAt": "2026-07-04T09:56:01.109Z", + "shards": [ + { + "domain": "condition", + "file": "condition.mcdx", + "bytes": 14348816, + "docCount": 103088, + "tokenCount": 8726 + }, + { + "domain": "med", + "file": "med.mcdx", + "bytes": 35034892, + "docCount": 381526, + "tokenCount": 40153 + }, + { + "domain": "lab", + "file": "lab.mcdx", + "bytes": 21644204, + "docCount": 185261, + "tokenCount": 22914 + }, + { + "domain": "procedure", + "file": "procedure.mcdx", + "bytes": 13623164, + "docCount": 99551, + "tokenCount": 9809 + }, + { + "domain": "vaccine", + "file": "vaccine.mcdx", + "bytes": 66024, + "docCount": 608, + "tokenCount": 489 + } + ] +} \ No newline at end of file diff --git a/.storybook/public/codify/en/med.mcdx b/.storybook/public/codify/en/med.mcdx new file mode 100644 index 00000000..8367154e --- /dev/null +++ b/.storybook/public/codify/en/med.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b97eb61b80a4709579a1dfc32a1540acee8c0cf22181933233f501782471784c +size 35034892 diff --git a/.storybook/public/codify/en/procedure.mcdx b/.storybook/public/codify/en/procedure.mcdx new file mode 100644 index 00000000..5be4d18d --- /dev/null +++ b/.storybook/public/codify/en/procedure.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea93aedd223cb9d3cabc4aaf143a78b7c7ac42aa01fdab53cbe23204333b56db +size 13623164 diff --git a/.storybook/public/codify/en/vaccine.mcdx b/.storybook/public/codify/en/vaccine.mcdx new file mode 100644 index 00000000..4afa2cf8 --- /dev/null +++ b/.storybook/public/codify/en/vaccine.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f2f014774c25de7767c94f658ace7ba3c6ffb54288fbce378ad81be2e7bce90 +size 66024 diff --git a/.storybook/public/codify/es/condition.mcdx b/.storybook/public/codify/es/condition.mcdx new file mode 100644 index 00000000..7bb1e2ee --- /dev/null +++ b/.storybook/public/codify/es/condition.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab69e4a16b6773b5bda6954bb92d3658212ebeffac0eee3284cc0764288f492 +size 22076 diff --git a/.storybook/public/codify/es/lab.mcdx b/.storybook/public/codify/es/lab.mcdx new file mode 100644 index 00000000..de7bdc0a --- /dev/null +++ b/.storybook/public/codify/es/lab.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38f77f7116a832d322cafd4cde513075badab8e2dbdc305972acd79283e80a93 +size 9560 diff --git a/.storybook/public/codify/es/manifest.json b/.storybook/public/codify/es/manifest.json new file mode 100644 index 00000000..931135ff --- /dev/null +++ b/.storybook/public/codify/es/manifest.json @@ -0,0 +1,28 @@ +{ + "version": 2, + "locale": "es", + "builtAt": "2026-07-04T09:56:09.625Z", + "shards": [ + { + "domain": "condition", + "file": "condition.mcdx", + "bytes": 22076, + "docCount": 170, + "tokenCount": 369 + }, + { + "domain": "med", + "file": "med.mcdx", + "bytes": 15804, + "docCount": 269, + "tokenCount": 146 + }, + { + "domain": "lab", + "file": "lab.mcdx", + "bytes": 9560, + "docCount": 143, + "tokenCount": 107 + } + ] +} \ No newline at end of file diff --git a/.storybook/public/codify/es/med.mcdx b/.storybook/public/codify/es/med.mcdx new file mode 100644 index 00000000..52e84c43 --- /dev/null +++ b/.storybook/public/codify/es/med.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd42a511a87465ffde91f45c5404e2c5531add067fea9875c5dc9b0053f15d17 +size 15804 diff --git a/Clinical-Med-Allergies-Conditions.md b/Clinical-Med-Allergies-Conditions.md new file mode 100644 index 00000000..4a0c259c --- /dev/null +++ b/Clinical-Med-Allergies-Conditions.md @@ -0,0 +1,660 @@ +# Medication, Allergy & Condition Model β€” Implementation Plan + +**Status:** Draft for developer review +**Scope:** A JSON medication/allergy/condition bucket on the encounter, a Zustand store to edit it, and UI for a med list + med/allergy editors + problem-list surfaces. Supports the full fidelity spectrum from *name-only* to *fully transmittable prescription*, captures uncertainty explicitly, models condition evolution (concern vs. assertion, Β§3.4), and serializes to **yabel**, **FHIR**, and our internal **`rxlist`** by unioning their requirements into one canonical model. + +--- + +## 1. The problem in one paragraph + +A clinician reviewing meds at an encounter almost never has uniform information. For one drug they have a full prescription (drug, sig, quantity, refills, pharmacy); for the next they have a name a patient mumbled and nothing else ("something for blood pressure, a water pill"). Today we force that into schemas β€” `rxlist`, FHIR, NCPDP β€” that were each designed for a *different* downstream purpose and each assume a *different* minimum completeness. The result is that partial knowledge either gets dropped or gets faked to satisfy a required field. This plan makes partial knowledge and uncertainty **first-class**, stores it once, and projects it out to whichever representation a consumer needs. + +The central design move: **one canonical record that is the *union* of what yabel, FHIR, and `rxlist` each need.** Editing happens against the canonical record. Each target format is a *projection* with its *own* required-field subset. A record is never globally "invalid" β€” it is only "not yet complete enough for target T." + +--- + +## 2. Architecture + +```mermaid +flowchart LR + subgraph inputs [Capture] + form["Med / Allergy editor
(React + shadcn/ui)"] + yabelin["yabel narrative
(AST from yabelFish)"] + hist["RxHistoryResponse
(SureScripts)"] + db["rxlist rows
(existing EHR meds)"] + end + + store["Zustand store
canonical union model
(the JSON bucket)"] + + subgraph outputs [Projections] + fhir["FHIR
MedicationStatement /
MedicationRequest / AllergyIntolerance"] + rxlist["rxlist / rxlist_refills /
rxlist_allergylist"] + yscript["yScript
NCPDP 2023011 messages"] + end + + form --> store + yabelin --> store + hist --> store + db --> store + + store --> fhir + store --> rxlist + store --> yscript + + store <-->|serialize| enc["encounter.medications[]
encounter.allergies[]"] +``` + +The Zustand store *is* the JSON bucket while editing; it serializes to `encounter.medications[]` / `encounter.allergies[]` for persistence. Everything else β€” FHIR, `rxlist`, yScript β€” is a pure function of the canonical record. + +--- + +## 3. The canonical data model + +TypeScript sketches below are illustrative β€” field names should be frozen against the real `rxlist` DDL and the 2023011 XSD before implementation (see Β§11). + +### 3.1 Medication record + +```ts +interface MedicationRecord { + // ---- identity & lineage ---- + id: string; // canonical, store-stable uuid + refs: { + instanceRef?: string; // yabel #med-1023 + rxid?: number; // rxlist.rxid + refillId?: number; // rxlist_refills.refill_id + fhirId?: string; + extId?: string; // rxlist.ext_id (e.g. SureScripts id) + }; + supersedes?: string; // canonical id of the record this one replaces + changeType?: // why it superseded (drives yScript decomposition) + | 'dose_change' | 'therapy_substitution' + | 'formulation_change' | 'frequency_change' | null; + + // ---- what it is ---- + drug: { + text: string; // free-text name β€” the ONLY hard-required field (Mode 0) + coding: Coding[]; // RxNorm / NDC / SNOMED + drugId?: number; // rxlist.drug_id (internal library) + medicationId?: number; // rxlist.medication_id + uncodedType?: 'CP' | 'SP' | null;// compound / supply + }; + strength?: string; // "20 MG" (rxlist.strength) + form?: string; + route?: { text?: string; routeId?: number }; + + // ---- how to take it ---- + sig?: { + text?: string; // provider directions (rxlist.sig) + patientText?: string; // plain-language (rxlist.sig_patient) + structured?: unknown; // FHIR Dosage / NCPDP codified sig β€” STUB, see Β§11 + }; + instructions?: string; // rxlist.instructions + indication?: IndicationLink & { text?: string }; // durable concern link + frozen coding (Β§3.4); + // text-only when no concern exists yet + + // ---- dispense / prescription layer (upper rungs of the ladder) ---- + dispense?: { + quantity?: { value: number; unit?: string }; // total_quantity / qty_uom + daysSupply?: number; // duration + refills?: number; // refills + maySubstitute?: boolean; // may_substitute + }; + pharmacy?: { ncpdpId?: string; name?: string }; + prescriber?: { npi?: string; name?: string; id?: number }; + + // ---- when ---- + period?: { + start?: string; startFuzzy?: string; // rxlist start_date / start_date_fuzzy + end?: string; endFuzzy?: string; // rxlist end_date / end_date_fuzzy + }; + + // ---- status & provenance ---- + status: MedStatus; + source: 'rxHistoryResponse' | 'ehrActive' | 'patientReported' | 'manuallyAdded'; + review: 'unreviewed' | 'confirmed' | 'reconciledIntoEhr' | 'discrepancy' | 'ignored'; + + // ---- uncertainty (first-class, see Β§4) ---- + uncertainty: Uncertainty; + + // ---- audit (mirrors rxlist revision columns) ---- + audit?: { enteredDate?: string; revisionDate?: string; revisionNo?: number; alterReason?: string }; +} + +type Coding = { system: 'RxNorm' | 'NDC' | 'SNOMED' | string; code: string; display?: string }; +``` + +### 3.2 Canonical status enum + +One enum, projected both ways. `changed` and `noMeds` are the ones that don't map cleanly to FHIR and need care. + +| Canonical `MedStatus` | `rxlist.status` | FHIR `MedicationStatement.status` | Notes | +|---|---|---|---| +| `active` | `1` | `active` | | +| `discontinued` | `2` | `stopped` | | +| `changed` | `3` | `completed` + `supersedes` | superseded by a newer record | +| `dispensed` | `-2` | `active` | | +| `reconciled` | `-3` | `active` | reconciled from external history | +| `deleted` | `-1` | `entered-in-error` | | +| `noMeds` | `4` | *(negative assertion, Β§4.4)* | "no medications" marker | +| `intended` | *(n/a)* | `intended` | planned, not yet started | + +### 3.3 Allergy record + +Parallel structure; reuses the same `Uncertainty` block. + +```ts +interface AllergyRecord { + id: string; + refs: { rxagyid?: number; fhirId?: string; extId?: string }; + substance: { + text: string; // hard-required (Mode 0 analog) + coding: Coding[]; + allergyId?: number; // rxlist_allergylist.allergy_id + agyidType?: number; // agyid_type + }; + type: 'allergy' | 'intolerance'; // rxlist intolerance 0 / 1 + reactions: Array<{ // rxlist reaction/reaction_severity + rxlist_allergy_reactions + manifestation?: string; + severity?: 'mild' | 'moderate' | 'severe'; + }>; + criticality?: 'low' | 'high' | 'unable-to-assess'; // FHIR + clinicalStatus: 'active' | 'inactive' | 'resolved'; // FHIR + verificationStatus: + | 'unconfirmed' | 'confirmed' | 'refuted' | 'entered-in-error'; // FHIR + rxlist status + period?: { start?: string; end?: string }; + comments?: string; + uncertainty: Uncertainty; +} +``` + +### 3.4 Conditions β€” concern vs. assertion + +**Developer primer β€” why conditions can't be modeled like meds.** Coded conditions *drift*: "prediabetes" becomes "type 2 diabetes" becomes β€” after a C-peptide test β€” "type 1 diabetes, actually", which later becomes "T1DM with neuropathy." Every downstream schema that treats **the code as the identity of the condition** breaks when this happens. The status-quo workaround β€” stamping an ICD-10 code onto each prescription and joining orders to problems *by code* in billing β€” decays exactly this way: recode the problem and every historical link silently unhooks. + +The fix is the classic concern-tracking pattern (openEHR / C-CDA Concern Act): split the model into + +- a **`ConditionConcern`** β€” the *stable identity* of "this patient's diabetes problem." It has a durable `concernId`, and **this is the only thing orders, encounters, and other records ever reference.** It never changes meaning; it only accumulates history. +- a series of **`ConditionAssertion`s** β€” time-stamped, coded characterizations of the concern. *These* carry the ICD-10/ICD-11/SNOMED codings, the verification status, severity, etc. When understanding evolves, we append a new assertion; we never rewrite an old one. + +```mermaid +flowchart LR + C["ConditionConcern C-42
'the diabetes problem'"] + A1["2019 Β· R73.03 prediabetes
provisional"] + A2["2020 Β· E11.9 T2DM
confirmed"] + A3["2023 Β· E10.9 T1DM
confirmed"] + A4["2025 Β· E10.42 T1DM w/ neuropathy
confirmed"] + C --- A1 + A1 -->|refinement| A2 + A2 -->|"revision
(E11.9 β†’ refuted)"| A3 + A3 -->|progression| A4 + Rx["order: insulin glargine"] -.->|"concernId C-42 +
frozen coding snapshot"| C +``` + +```ts +interface ConditionConcern { + concernId: string; // durable uuid — the ONLY id other records reference + refs: { problemId?: number; fhirId?: string; extId?: string }; // freeze against real problem-list DDL + clinicalStatus: 'active' | 'recurrence' | 'relapse' | 'inactive' | 'remission' | 'resolved'; + assertions: ConditionAssertion[]; // ordered; last non-refuted = current characterization + relationships?: Array<{ + type: 'caused-by' | 'evolved-from' | 'differential-sibling' | 'complication-of'; + concernId: string; + }>; + source: 'ehrProblemList' | 'patientReported' | 'manuallyAdded' | 'claimsHistory'; + review: 'unreviewed' | 'confirmed' | 'reconciledIntoEhr' | 'discrepancy' | 'ignored'; + comments?: string; +} + +interface ConditionAssertion { + id: string; + supersedes?: string; // prior assertion id (same machinery as med supersedes, §3.1) + changeType?: // why it superseded + | 'refinement' // more specific, same disease (dementia → ischemic dementia) + | 'revision' // prior was wrong; prior becomes verificationStatus 'refuted' (T2DM → T1DM) + | 'progression' // disease advanced (T1DM → T1DM w/ neuropathy) + | 'reattribution' // root cause re-assigned; usually pairs with a 'caused-by' relationship + | null; + + // ---- what it is (at this point in time) ---- + condition: { + text: string; // free-text name — the ONLY hard-required field (Mode 0 analog) + coding: ConditionCoding[]; // ICD-10 / ICD-11 / SNOMED — zero or more, may coexist + }; + category?: 'problem-list-item' | 'encounter-diagnosis' | 'health-concern'; + verificationStatus: + | 'unconfirmed' | 'provisional' | 'differential' + | 'confirmed' | 'refuted' | 'entered-in-error'; // FHIR Condition.verificationStatus + severity?: 'mild' | 'moderate' | 'severe'; + bodySite?: { text?: string; coding?: Coding[] }; // SNOMED body-structure codes + laterality?: 'left' | 'right' | 'bilateral'; // ICD-10/11 often encode this in the code itself + + // ---- when ---- + onset?: { date?: string; fuzzy?: string }; // same fuzzy pattern as meds (§4.2) + abatement?: { date?: string; fuzzy?: string }; + effectivePeriod: { start: string; end?: string }; // when this WAS the characterization (trend axis) + + // ---- provenance ---- + encounterId?: string; // where this assertion was made + uncertainty: Uncertainty; + comments?: string; +} + +type ConditionCoding = Coding & { + system: 'ICD-10-CM' | 'ICD-11' | 'SNOMED' | string; + primary?: boolean; // which coding is authoritative for this assertion + mappedFrom?: string; // provenance when derived via a crosswalk (e.g. SNOMED→ICD-10 map) +}; +``` + +Coding-system notes: + +| System | FHIR `system` URI | Role | Notes | +|---|---|---|---| +| ICD-10-CM | `http://hl7.org/fhir/sid/icd-10-cm` | Billing / claims | Laterality & episode baked into the code; US claims still require it. | +| ICD-11 (MMS) | `http://id.who.int/icd/release/11/mms` | Forward compatibility / international | Supports post-coordination (stem + extension codes) — store the full cluster string in `code`. | +| SNOMED CT | `http://snomed.info/sct` | Clinical semantics | Preferred `primary` coding; richest for decision support and maps *out* to both ICD revisions. | + +**Refinement vs. revision — the two kinds of change are semantically different:** + +| Change | Example | Operation | Prior assertion becomes | +|---|---|---|---| +| **Refinement** | dementia → ischemic dementia | new assertion, `changeType: 'refinement'` | superseded (was true-as-known) | +| **Revision** | differential T2DM → actually T1DM | new assertion, `changeType: 'revision'` | **`refuted`** (was never true) | +| **Progression** | T2DM → T2DM w/ neuropathy | new assertion, `changeType: 'progression'` | superseded | +| **Re-attribution** | "dementia" → caused by cerebrovascular disease | new assertion + `caused-by` relationship to a (possibly new) concern | superseded | + +The classification is mostly mechanical via the SNOMED **is-a** hierarchy: if the new code is an is-a *descendant* of the old (vascular dementia is-a dementia) it's a refinement; if it's a *sibling or disjoint branch* (T1DM vs T2DM are siblings under diabetes mellitus) it's a revision. The UI uses this to *suggest* the `changeType` at write time — the terminology tree classifies transitions, it is **not** used to reverse-engineer linkage at read time. + +**Order linkage — reference + frozen snapshot, always both.** A prescription's (or any order's) indication carries three things: + +```ts +interface IndicationLink { + concernId: string; // durable clinical link — survives any recoding + assertionId?: string; // which characterization was current when ordered + coding?: ConditionCoding[]; // FROZEN snapshot for billing/audit — a projection, never the join key +} +``` + +`concernId` answers "what problem is this med for?" forever — the join can't decay because the identity isn't the code. The frozen `coding` answers "what did we believe/bill at the time?" forever — honest history even after a revision. The ICD-10 that lands on the claim is a **point-in-time projection** (§8) of the concern's current assertion at transaction time; the claim is *supposed* to be a snapshot, the EHR just must never use the claim's code as its own join key. + +**Trending without lying** — two query paths: + +1. **Clinical story:** walk the assertion chain (`supersedes` + `changeType`). "First noted 2019 as prediabetes, revised to T1DM 2023, neuropathy 2025" falls straight out — no inference needed. +2. **Cohorts / analytics:** query assertions by `effectivePeriod`, aggregate via SNOMED is-a *descendants* ("any descendant of 73211009 diabetes mellitus active in 2024") so refinements don't drop patients from cohorts, and exclude `refuted` assertions so revisions don't pollute the trend. + +Terminology inference (is-a traversal) is used to **suggest links at capture time** and **narrate/roll up at read time** — never as the system of record for linkage. Explicit skeleton, inferred flesh. + +Design rules mirroring the med model: + +- **Capture-first.** "high blood pressure" saves instantly as a text-only assertion on a new concern; coding is progressive enrichment via terminology autocomplete (SNOMED-first, ICD-10/ICD-11 derived through published crosswalks where the map is 1:1 — ambiguous maps surface a picker, never auto-select). +- **Uncertainty applies.** `verificationStatus: 'provisional' | 'differential'` covers diagnostic uncertainty; the shared `uncertainty.fields` block covers field-level softness (fuzzy onset — "since her twenties"). +- **Round-trip preservation.** Codings the store did not originate pass through untouched; `mappedFrom` records crosswalk provenance so a re-map never silently overwrites a hand-picked code. +- **Negative assertion.** `noKnownProblems` on the bucket (§4.4) parallels NKDA — FHIR export uses SNOMED `160245001` (no current problems or disability). + +FHIR projection: concern → `Condition` (logical id = `concernId`), each assertion → a `Condition` version + `Provenance`; `IndicationLink` → `MedicationRequest.reasonReference`. + +### 3.5 Encounter scope & the three problem surfaces + +Conditions live on **two levels** that must not be conflated: the **patient-level problem list** (longitudinal concerns, the holistic record) and the **encounter-level view** of those concerns. A cardiologist neither wants nor should be forced to curate the patient's psoriasis — they maintain a *Relevant* Problem List. The model must let them do that **without their encounter being misread as a holistic review** ("the cardiologist didn't mention psoriasis, so it must be resolved" is exactly the inference we must make impossible). + +Three UI surfaces, one data spine: + +| Surface | Level | Contents | Component (§9) | +|---|---|---|---| +| **Problem List** | patient | all `ConditionConcern`s, full assertion history | `` | +| **Presenting Problems** (Medical History) | encounter | *references* to concerns deemed relevant this visit + ad-hoc additions | `` | +| **Assessment (& Plan)** | encounter | today's assertion per addressed concern, with orders nested under each problem | `` | + +```ts +interface EncounterConditions { + scope: 'problem-focused' | 'comprehensive'; // chosen by the provider at encounter start + + // concerns touched this visit — new ones, or copies of patient concerns being edited + concerns: ConditionConcern[]; + + // Presenting Problems / Relevant Medical History — references, not copies + presenting: Array<{ + concernId: string; + relevance: 'addressed' | 'relevant-history' | 'noted'; // tag lives on the REFERENCE, + comments?: string; // never on the concern itself + }>; + + // Assessment & Plan — visit-specific + assessment: Array<{ + concernId: string; + assertionId: string; // today's assertion (even a no-change "addressed today" one) + orders: Array<{ + orderId: string; // med / lab / procedure — renders indented under the problem + type: 'medication' | 'lab' | 'imaging' | 'procedure' | 'referral'; + }>; + note?: string; + }>; + + noKnownProblems: boolean; // only assertable when scope === 'comprehensive' +} +``` + +Key semantics: + +- **Relevance lives on the reference, not the concern.** "Relevant to my specialty" is an encounter-scoped judgment; it must never pollute the patient-level record or another specialist's view. +- **Presenting Problems is fed by the Problem List.** The default flow is *select* from patient concerns (+ relevance tag); ad-hoc capture-first additions create a new concern in `concerns` and a reference in `presenting`. +- **Every assessed problem gets a per-visit assertion.** Even "no change, addressed today" appends a lightweight assertion (same coding, new `effectivePeriod`) — this gives trending an honest per-visit data point and makes the billing projection (assessed problems → claim diagnoses) trivial. +- **Assessment & Plan nesting** is pure `IndicationLink` traversal: orders carrying `concernId` X render indented under problem X; orders with no link render in an "unlinked" bucket the UI nags about. + +**Encounter close — scope gates the merge.** When the encounter closes, `EncounterConditions` is merged into the patient object by a projection (§8) whose behavior the `scope` flag switches: + +| On close | `comprehensive` | `problem-focused` | +|---|---|---| +| Touched concerns (new assertions, status changes) | upsert into patient problem list | upsert into patient problem list | +| Untouched patient concerns | may be marked reviewed / resolved / refuted as part of full reconciliation | **left strictly untouched** | +| Negative assertions (`noKnownProblems`) | allowed | **forbidden** — absence of mention asserts nothing | +| Patient-level "last full review" timestamp | updated | not updated | + +This is the condition-side instance of the doc's core rule: **we never block capture — we gate projection.** A problem-focused encounter captures exactly what the specialist attended to; the close projection guarantees that partial attention is never inflated into a holistic claim. + +--- + +## 4. Uncertainty as a first-class citizen + +This is the requirement that most schemas get wrong. Uncertainty is multi-dimensional, so we model it explicitly rather than smuggling it into free-text notes. + +```ts +interface Uncertainty { + overall: 'confirmed' | 'low' | 'medium' | 'high'; + adherence: 'taking' | 'not-taking' | 'unsure' | 'unknown'; + verification: 'unverified' | 'patient-reported' | 'pharmacy-verified' | 'prescriber-confirmed'; + fields: Partial>; +} + +interface FieldUncertainty { + known: boolean; + reason?: 'asked-unknown' | 'not-asked' | 'masked' | 'unavailable'; // ~ FHIR data-absent-reason + confidence?: 'low' | 'medium' | 'high'; + note?: string; // "patient thinks 20 but not sure" +} + +type UncertainField = 'strength' | 'sig' | 'form' | 'route' | 'period' | 'indication' | 'dispense'; +``` + +### 4.1 The three-state rule for every field + +The single most important semantic in the whole design. Any optional field has **three** distinct states the UI and model must keep separate: + +| State | Value | `fields[x]` flag | Meaning | +|---|---|---|---| +| **Confident** | set | absent | We know it. | +| **Uncertain** | set | `{ known: true, confidence: 'low', note }` | We have a value but flag it as soft. | +| **Explicitly unknown** | *empty* | `{ known: false, reason: 'asked-unknown' }` | We asked; patient doesn't know. | +| **Untouched** | *empty* | absent | Not yet entered. **≠ unknown.** | + +"Blank" must never silently mean "unknown." The clinician has to be able to *assert* unknown (which is clinically meaningful — "I asked, they don't know the dose") distinctly from not having gotten to the field yet. This is the crux of "capture uncertainty." + +### 4.2 Temporal fuzziness — we already have the columns + +`rxlist` carries `start_date` **and** `start_date_fuzzy` (same for end). Use them: `period.start` holds an exact ISO date when known; `period.startFuzzy` holds a partial/human string ("early 2024", "~3 months ago") when that's all the clinician has. FHIR export maps fuzzy values to `dataAbsentReason` + a note, or to a partial `dateTime` where the precision allows. + +### 4.3 Existence vs. adherence uncertainty + +`uncertainty.adherence` captures "patient says they were prescribed this but stopped taking it" (`not-taking`) or "not sure if still on it" (`unsure`) — orthogonal to whether the *record's fields* are known. `verification` captures source reliability, and doubles as the reconciliation confidence signal. + +### 4.4 Negative assertions (NKDA / No Meds) + +Absence of records is **not** the same as an asserted "none." Model these as explicit flags on the bucket so they round-trip: + +```ts +interface MedBucket { + medications: MedicationRecord[]; + allergies: AllergyRecord[]; + conditions: ConditionConcern[]; // patient-level; encounter-level lives in EncounterConditions (§3.5) + noKnownMedications: boolean; // → rxlist NOMEDS (status 4) + noKnownAllergies: boolean; // NKDA → FHIR AllergyIntolerance code SNOMED 716186003 + noKnownProblems: boolean; // → FHIR Condition code SNOMED 160245001 +} +``` + +Setting `noKnownAllergies` with allergy records present (or `noKnownProblems` with condition records present) is a validation conflict the UI must surface. + +--- + +## 5. The fidelity ladder ("modes") + +Modes are **derived**, not stored. `mode(record)` is a pure function of which fields are populated (counting "explicitly unknown" as *addressed*, not missing). The UI reads the current rung to decide which affordances to show and what's needed to climb. + +```mermaid +flowchart TD + m0["Mode 0 · Named
drug.text"] + m1["Mode 1 Β· Specified
+ strength / form"] + m2["Mode 2 Β· Directed
+ sig"] + m3["Mode 3 Β· Prescribable
+ quantity, daysSupply, refills"] + m4["Mode 4 Β· Transmittable
+ pharmacy, prescriber, substitution"] + m0 --> m1 --> m2 --> m3 --> m4 + + m0 -.->|"valid MedicationStatement
valid rxlist row"| ok0[" "] + m2 -.->|"usable presenting med"| ok2[" "] + m3 -.->|"FHIR MedicationRequest"| ok3[" "] + m4 -.->|"NewRx / yScript eligible"| ok4[" "] +``` + +| Mode | Adds | Enables | Example | +|---|---|---|---| +| 0 Β· Named | `drug.text` | presenting-list entry, statement | "lisinopril" | +| 1 Β· Specified | strength/form | coded matching | "lisinopril 10 mg tablet" | +| 2 Β· Directed | `sig` | usable MedicationStatement | "lisinopril 10 mg po daily" | +| 3 Β· Prescribable | quantity, days supply, refills | FHIR MedicationRequest | + "#30, 3 refills" | +| 4 Β· Transmittable | pharmacy, prescriber, substitution | **NewRx via yScript** | + "β†’ Walgreens #4021" | + +The ladder is the gate on downstream capability: only a **Mode 4** record can generate a NewRx (this is exactly the yScript eligibility rule from the prior design). A Mode 2 record is a perfectly good presenting med but cannot transmit. **We never block capture on mode β€” we gate projection.** + +--- + +## 6. The union field map + +This is the heart of the "union the requirements" instruction and the part to review most carefully. Canonical field on the left; how each target consumes it; and the lowest mode at which the field is *required for that target*. + +| Canonical | `rxlist` column | FHIR path | yabel node | Req. for target @ mode | +|---|---|---|---|---| +| `drug.text` | `drug_name` | `medication.text` | statement head | all @ 0 | +| `drug.coding[RxNorm]` | `medication_id` | `medicationCodeableConcept.coding` | coding hint | FHIR/NewRx @ 1 | +| `drug.drugId` | `drug_id` | *(ext)* | β€” | rxlist | +| `drug.uncodedType` | `uncoded_type` | *(ext)* | β€” | rxlist | +| `strength` | `strength` | part of coded product / `.text` | strength token | NewRx @ 1 | +| `route` | `route_id` | `dosage.route` | route token | β€” | +| `sig.text` | `sig` | `dosageInstruction.text` | sig line | statement @ 2, NewRx @ 2 | +| `sig.patientText` | `sig_patient` | `dosageInstruction.patientInstruction` | β€” | β€” | +| `sig.structured` | *(n/a)* | `dosageInstruction.timing/doseAndRate` | β€” | NewRx (codified) | +| `indication` | `indication` / `ind_code` / `ind_code_sys` | `reasonCode` | β€” | β€” | +| `instructions` | `instructions` | `note` | β€” | β€” | +| `dispense.quantity` | `total_quantity` / `qty_uom` | `dispenseRequest.quantity` | β€” | NewRx @ 3 | +| `dispense.daysSupply` | `duration` | `dispenseRequest.expectedSupplyDuration` | β€” | NewRx @ 3 | +| `dispense.refills` | `refills` | `dispenseRequest.numberOfRepeatsAllowed` | β€” | NewRx @ 3 | +| `dispense.maySubstitute` | `may_substitute` | `substitution.allowed` | β€” | NewRx @ 4 | +| `pharmacy` | *(refills/newrx)* | `dispenseRequest.performer` | β€” | NewRx @ 4 | +| `prescriber` | `doctor_id` | `requester` | β€” | NewRx @ 4 | +| `period.start` | `start_date` | `effectivePeriod.start` | timeline | β€” | +| `period.startFuzzy` | `start_date_fuzzy` | `dataAbsentReason` + note | timeline (fuzzy) | β€” | +| `status` | `status` | `.status` (see Β§3.2) | lifecycle | all | +| `source` | `interface` | `informationSource` | provenance hint | β€” | +| `refs.extId` | `ext_id` | `identifier` | β€” | β€” | +| `supersedes` | `changed_from_rxid` | `priorPrescription` / `basedOn` | `relatedTo` | β€” | +| `uncertainty.fields[x]` | *(none β€” new)* | `dataAbsentReason`, `.text` notes | inlay hint | β€” | + +The gaps are informative: **`rxlist` has no home for structured per-field uncertainty** (only the fuzzy-date pair) β€” that's net-new state the canonical model adds and that only FHIR (via `dataAbsentReason`) partially absorbs on export. Conversely, `uncodedType` and `drugId` are `rxlist`-only and must be preserved as opaque passthrough so a round-trip through the store doesn't lose them. + +Allergy map (condensed): + +| Canonical | `rxlist_allergylist` | FHIR `AllergyIntolerance` | +|---|---|---| +| `substance.text` | `allergy_name` | `code.text` | +| `type` | `intolerance` (0/1) | `type` (allergy/intolerance) | +| `reactions[].manifestation` | `reaction` / `rxlist_allergy_reactions` | `reaction.manifestation` | +| `reactions[].severity` | `reaction_severity` | `reaction.severity` | +| `verificationStatus` | `status` | `verificationStatus` | +| `period` | `start_date` / `end_date` | `onset` / `lastOccurrence` | + +--- + +## 7. Zustand store + +### 7.1 Shape + +```ts +interface MedStore { + bucket: MedBucket; // the serializable JSON (Β§4.4) + + // mutations β€” meds + addMed(partial: Partial): string; // returns id; Mode 0 is enough + updateMedField(id: string, key: K, val: MedicationRecord[K]): void; + setFieldUnknown(id: string, field: UncertainField, reason: FieldUncertainty['reason']): void; + setFieldConfidence(id: string, field: UncertainField, confidence: FieldUncertainty['confidence'], note?: string): void; + setStatus(id: string, status: MedStatus): void; + supersede(priorId: string, next: Partial, changeType: MedicationRecord['changeType']): string; + removeMed(id: string): void; + + // mutations β€” allergies + addAllergy(partial: Partial): string; + updateAllergy(id: string, patch: Partial): void; + removeAllergy(id: string): void; + + // mutations β€” conditions (concern/assertion model, Β§3.4–§3.5) + addConcern(assertion: Partial): string; // new concern + first assertion; text-only OK + assertCondition(concernId: string, next: Partial, + changeType: ConditionAssertion['changeType']): string; // refine / revise / progress + relateConcerns(fromId: string, type: 'caused-by' | 'evolved-from' | 'differential-sibling' | 'complication-of', toId: string): void; + setConcernStatus(concernId: string, status: ConditionConcern['clinicalStatus']): void; + setEncounterScope(scope: 'problem-focused' | 'comprehensive'): void; + setPresenting(concernId: string, relevance: 'addressed' | 'relevant-history' | 'noted' | null): void; + linkOrderToConcern(orderId: string, concernId: string): void; // writes IndicationLink w/ frozen coding + removeConcern(concernId: string): void; + + // negative assertions + setNoKnownMedications(v: boolean): void; + setNoKnownAllergies(v: boolean): void; + setNoKnownProblems(v: boolean): void; + + // ingestion + ingestRxlist(rows: RxlistRow[]): void; + ingestHistory(resp: RxHistoryResponse): void; // sets source='rxHistoryResponse', review='unreviewed' + ingestYabelAst(ast: YabelAst): void; +} +``` + +### 7.2 Selectors (derived, memoized) + +```ts +selectMode(id): 0|1|2|3|4 // Β§5 +selectCompleteness(id): { mode, missingForNext: string[] } +selectPresentingList(): MedicationRecord[] // source-tagged review list +selectActive(): MedicationRecord[] +selectDiscrepancies(): MedicationRecord[] // history vs ehrActive conflicts +selectTransmittable(): MedicationRecord[] // mode === 4 && status in {active, changed} +selectValidationForTarget(id, target: 'fhir'|'rxlist'|'newrx'): Diagnostic[] +``` + +### 7.3 Middleware + +- **immer** β€” ergonomic nested updates (the record is deep). +- **persist** β€” *decision required* (Β§11): PHI in browser storage is a policy call. Default recommendation: **do not** persist to `localStorage`; the encounter JSON is the server-side source of truth and the store hydrates from it per session. +- **zundo** (or equivalent) β€” undo/redo, and the natural place to emit `audit.revisionNo` increments that mirror `rxlist_revisions`. + +### 7.4 Serialization to the encounter + +`bucket` *is* `encounter.medications[]` + `encounter.allergies[]` + `encounter.conditions` (Β§3.5 `EncounterConditions`) + the negative-assertion flags. One `serialize()` / `hydrate()` pair; no transformation, because the canonical model is designed to be the on-disk bucket shape. Projections to FHIR/`rxlist`/yScript β€” and the scope-gated `closeEncounter` merge β€” are separate, on demand. + +--- + +## 8. Projections (adapters) + +Pure functions, each in its own module, each with its own required-field validation keyed to the mode ladder. + +``` +projections/ + toFhir.ts record β†’ MedicationStatement | MedicationRequest ; allergy β†’ AllergyIntolerance ; + concern β†’ Condition (id = concernId; assertions β†’ versions + Provenance) + toRxlist.ts record β†’ rxlist (+ rxlist_refills when mode β‰₯ 3) + toYscript.ts record β†’ NCPDP 2023011 message(s) [only mode 4; decomposes per changeType] + closeEncounter.ts EncounterConditions β†’ patient problem-list merge [behavior gated by scope, Β§3.5] + fromRxlist.ts rxlist row(s) β†’ record + fromHistory.ts RxHistoryResponse entry β†’ record (source-tagged) + fromYabel.ts yabel AST node β†’ record +``` + +Two rules every projection obeys: + +1. **Validate against the target's own minimum, not the store's.** `toYscript` throws/returns diagnostics if `mode < 4`; `toFhir` for a `MedicationStatement` accepts `mode 0`. +2. **Round-trip preservation.** `fromRxlist β†’ toRxlist` must not lose `uncodedType`, `drugId`, `extId`, fuzzy dates, or the uncertainty block (the last has no `rxlist` home, so if the pipeline ever persists *only* to `rxlist`, uncertainty is dropped β€” flag this to product; it may argue for a sidecar table or keeping the bucket authoritative). + +`toYscript` reuses the decomposition logic already specified: a `dose_change` supersede β†’ **CancelRx** (prior) + **NewRx** (next); a `stop` β†’ **CancelRx** only; a Mode-4 new med β†’ **NewRx**. + +--- + +## 9. UI components + +Three surfaces, built on the shadcn/ui primitives already in use (segmented controls, auto-growing textareas, two-column layouts). + +### 9.1 `` β€” review surface + +The "list of meds" view. Grouped by `status`, each row shows: name + strength, a **source badge** (RxHistory / EHR / patient-reported), a **completeness pip** (which rung on the ladder), and a **discrepancy flag** when history and EHR disagree. Rows expand into the editor. This is where reconciliation happens β€” confirm, ignore, or reconcile-into-EHR per row. + +### 9.2 `` β€” progressive capture + +The core of the "various modes" requirement. Design principles: + +- **Capture-first, structure-later.** A single text box accepts "lisinopril" and saves a Mode 0 record instantly. Everything else is progressive disclosure β€” the form *grows* as the clinician adds detail, and always shows the current rung + a quiet "add strength / sig / quantity to make this prescribable" nudge. +- **Every optional field has an "unknown" affordance** distinct from leaving it blank β€” a small toggle that sets `known: false` (Β§4.1). Visually distinct from empty. +- **Confidence toggle** per soft field (a low/med/high control) writing `confidence` + optional note. +- **Fuzzy date input** β€” a date field that also accepts free text ("early last year"), routing to `period.startFuzzy`. +- **Supersede action** β€” "change this med" opens a pre-filled editor whose save calls `supersede()` with a `changeType`, preserving lineage (and feeding yScript downstream). + +### 9.3 `` β€” parallel capture + +Same uncertainty machinery. Substance + type (allergy/intolerance segmented control) + reaction list (repeatable manifestation + severity) + verification status. An **NKDA toggle** at the list level sets `noKnownAllergies` and warns if allergy records exist. + +### 9.4 `` β€” patient-level concerns + +The longitudinal surface (Β§3.5). One row per `ConditionConcern`, grouped by `clinicalStatus`; each row shows the *current* assertion's name + coding chips (ICD-10 / ICD-11 / SNOMED badges, `primary` highlighted) and expands into the **assertion timeline** β€” the supersede chain with `changeType` badges (refinement / revision / progression) and refuted assertions struck through. Row actions: **refine**, **revise** (warns it will refute the prior), **resolve**, **relate** (caused-by etc.), **add problem** (capture-first text box). + +### 9.5 `` β€” encounter relevance + +The encounter's Relevant Problem List / Medical History. A **scope banner** (problem-focused vs comprehensive, Β§3.5) heads the surface. Primary flow: pick concerns from the patient Problem List and tag each reference `addressed` / `relevant-history` / `noted`; ad-hoc capture-first additions create a new concern and reference it. In problem-focused scope the component makes visually clear that unselected concerns are *out of scope*, not absent (and the `noKnownProblems` toggle is disabled). + +### 9.6 `` β€” visit A&P + +One block per assessed concern showing today's assertion (name, coding, verification status) with an inline refine/revise affordance that appends a per-visit assertion (Β§3.5). A **"& Plan" toggle** expands each block to render its linked orders **indented under the problem** (pure `IndicationLink.concernId` traversal β€” med / lab / imaging / procedure / referral rows). Orders with no concern link collect in an **unlinked bucket** at the bottom with a one-click "link to problem" suggestion driven by terminology proximity (suggest-only, Β§3.4). + +--- + +## 10. Validation strategy + +Validation is **layered and per-target**, never global: + +1. **Store-level (soft):** the only hard requirement is `drug.text` / `substance.text`. Everything else is capturable at any fidelity. Store validation produces *warnings*, not blocks. +2. **Mode-level (derived):** `selectCompleteness` reports the current rung and what's missing for the next β€” informational, drives UI nudges. +3. **Target-level (hard, on projection):** each adapter enforces its own minimum. `toYscript` requires Mode 4; `toFhir` MedicationRequest requires Mode 3; `toRxlist` requires only Mode 0. A record failing a target's check returns `Diagnostic[]` rather than throwing into the UI. + +This layering is the concrete meaning of "union the requirements": the canonical schema is the **superset** of fields; validation is the **per-target subset** check. (This mirrors the single-schema / many-conformance-targets pattern β€” worth keeping the target minimums declarative so they're one source of truth rather than scattered `if` checks.) + +--- + +## 11. Open questions / validate before building + +- **PHI persistence policy.** Confirm the store should *not* persist to browser storage and that the encounter JSON is authoritative. (Recommended default above.) +- **`rxlist` has no uncertainty home.** If any pipeline persists solely to `rxlist`, the uncertainty block is dropped. Decide: keep the bucket authoritative, add a sidecar, or accept lossy `rxlist` export. +- **Structured sig.** Adopt FHIR `Dosage` as the internal canonical structured-sig shape and project *out* to NCPDP codified sig? Validate the 2023011 codified-sig structure against the actual XSD before freezing `sig.structured`. +- **Coding resolution.** Where do RxNorm/NDC codes and `drug_id` come from during capture β€” an autocomplete service against the WebChart drug library? Mode 0 must not require it. +- **2023011 specifics.** The message decomposition, three-way-coordination fields, and quantity qualifiers should be validated against the implementation guide, consistent with the yScript caveat. +- **Supersede chains.** How deep do lineage chains render in the list UI, and does `changed_from_rxid` round-trip through multi-step chains without ambiguity? +- **Condition persistence target.** Identify the internal problem-list table analogous to `rxlist` and freeze `ConditionConcern.refs` against its DDL. Also decide which crosswalk sources (SNOMEDβ†’ICD-10-CM map, WHO ICD-11 mapping tables) the autocomplete/re-map service uses. +- **Per-visit assertions volume.** "Addressed today" assertions (Β§3.5) give honest trend points but multiply rows β€” confirm the problem-list table (or a sidecar assertions table) can carry them, or whether no-change assertions collapse into a touch timestamp. +- **Encounter-close merge conflicts.** Two open problem-focused encounters touching the same concern: last-write-wins, or three-way merge on the assertion chain? +- **RxNorm codes in examples** here are illustrative and not verified. + +--- + +## 12. Suggested build phases + +1. **Canonical model + store + encounter (de)serialization.** Capture, list, and edit at all modes with full uncertainty. No projections yet. Ships the editing UX end-to-end against the JSON bucket. +2. **Projections out + validation.** `toFhir`, `toRxlist`, `mode`/`completeness` derivation, per-target diagnostics. +3. **Ingestion + reconciliation.** `fromRxlist`, `fromHistory` (with source tagging), `fromYabel`; discrepancy detection in the list. +4. **yScript hook.** Mode-4 β†’ transmission staging; wire supersede `changeType` to CancelRx/NewRx decomposition. +5. **Allergy parity + negative assertions + revisions.** NKDA/NoMeds, `zundo` undo mirroring `rxlist_revisions`. +6. **Conditions.** Concern/assertion model (Β§3.4) + ICD-10/ICD-11/SNOMED coding autocomplete; ``, ``, `` components (Β§9.4–9.6); `IndicationLink` order linkage; encounter scope + `closeEncounter` merge projection; `noKnownProblems`; FHIR `Condition` projection. \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 34e3210d..9e68f315 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -90,6 +90,19 @@ export default [ // Encoding APIs atob: 'readonly', btoa: 'readonly', + TextDecoder: 'readonly', + TextEncoder: 'readonly', + // Workers / fetch / binary + Worker: 'readonly', + fetch: 'readonly', + Response: 'readonly', + MessageEvent: 'readonly', + self: 'readonly', + performance: 'readonly', + DataView: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Int32Array: 'readonly', // Observer APIs IntersectionObserver: 'readonly', IntersectionObserverEntry: 'readonly', diff --git a/package.json b/package.json index 681a1ceb..5d441045 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,9 @@ "copy:markdown-css": "mkdir -p dist/components/Markdown && cp src/components/Markdown/styles.css dist/components/Markdown/ 2>/dev/null || true", "copy:kerebron-css": "cp src/styles/kerebron.css dist/kerebron.css 2>/dev/null || true", "typecheck": "tsc --noEmit", + "codify:extract": "node packages/codify/scripts/extract.mjs", + "codify:build": "node packages/codify/scripts/build-all.mjs", + "codify:build:db": "node packages/codify/scripts/build-sqlite.mjs", "lint": "eslint \"src/**/*.{ts,tsx}\"", "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", "format": "prettier --check \"src/**/*.{ts,tsx,css}\"", diff --git a/packages/codify b/packages/codify new file mode 160000 index 00000000..d2305d95 --- /dev/null +++ b/packages/codify @@ -0,0 +1 @@ +Subproject commit d2305d95592b7325daa33b66da299ddc81ac5722 diff --git a/src/components/Assessment/Assessment.stories.tsx b/src/components/Assessment/Assessment.stories.tsx new file mode 100644 index 00000000..de66e58b --- /dev/null +++ b/src/components/Assessment/Assessment.stories.tsx @@ -0,0 +1,343 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { + Assessment, + type AssessmentItem, + type AssessmentOrder, +} from './Assessment'; +import { CodeLookup, type CodifyDomain } from '../CodeLookup'; +import { + ConditionEditor, + type ConditionAssertionDraft, +} from '../ConditionEditor'; +import type { ConditionAssertion, ConditionConcern } from '../ProblemList'; + +const meta: Meta = { + title: 'Healthcare/Assessment', + component: Assessment, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +Visit-specific **Assessment & Plan**. + +- One block per assessed problem showing today's assertion (name, verification status, coding chips). +- With the plan enabled, orders linked to the problem β€” via \`concernId\`, the durable IndicationLink that survives recoding β€” render **indented under the problem**. +- Orders without a problem link collect in an **unlinked bucket** with one-click link chips. + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const concerns: ConditionConcern[] = [ + { + concernId: 'C-42', + clinicalStatus: 'active', + assertions: [ + { + id: 'A-4', + date: '2026-07-03', + text: 'Type 1 diabetes mellitus with neuropathy', + verificationStatus: 'confirmed', + coding: [ + { system: 'ICD-10-CM', code: 'E10.42', primary: true }, + { system: 'SNOMED', code: '426875007' }, + ], + }, + ], + }, + { + concernId: 'C-11', + clinicalStatus: 'active', + assertions: [ + { + id: 'A-15', + date: '2026-07-03', + text: 'Essential hypertension', + verificationStatus: 'confirmed', + coding: [{ system: 'ICD-10-CM', code: 'I10', primary: true }], + }, + ], + }, + { + concernId: 'C-15', + clinicalStatus: 'active', + assertions: [ + { + id: 'A-20', + date: '2026-07-03', + text: 'Hypothyroidism, suspected', + verificationStatus: 'provisional', + coding: [{ system: 'ICD-10-CM', code: 'E03.9' }], + }, + ], + }, +]; + +const sampleItems: AssessmentItem[] = [ + { + concernId: 'C-42', + assertionId: 'A-4', + note: 'A1c 7.2, improved from 7.9. Neuropathic pain controlled. Continue current regimen.', + }, + { + concernId: 'C-11', + assertionId: 'A-15', + note: 'BP 132/84 today. Continue lisinopril.', + }, + { + concernId: 'C-15', + assertionId: 'A-20', + note: 'Fatigue and cold intolerance. Check TSH.', + }, +]; + +const sampleOrders: AssessmentOrder[] = [ + { + orderId: 'O-1', + type: 'medication', + display: 'insulin glargine 20 units subcutaneous qhs', + concernId: 'C-42', + }, + { + orderId: 'O-2', + type: 'medication', + display: 'gabapentin 300 mg capsule', + detail: '1 capsule po tid', + concernId: 'C-42', + }, + { + orderId: 'O-3', + type: 'lab', + display: 'Hemoglobin A1c', + detail: 'in 3 months', + concernId: 'C-42', + }, + { + orderId: 'O-4', + type: 'referral', + display: 'Podiatry β€” diabetic foot exam', + concernId: 'C-42', + }, + { + orderId: 'O-5', + type: 'medication', + display: 'lisinopril 10 mg tablet', + detail: '1 tablet po daily', + concernId: 'C-11', + }, + { + orderId: 'O-6', + type: 'lab', + display: 'TSH with reflex to free T4', + }, + { + orderId: 'O-7', + type: 'imaging', + display: 'Chest X-ray, 2 views', + }, +]; + +function InteractiveTemplate() { + const [concernList, setConcernList] = useState(concerns); + const [items, setItems] = useState(sampleItems); + const [orders, setOrders] = useState(sampleOrders); + const [showPlan, setShowPlan] = useState(true); + const [editor, setEditor] = useState<{ + mode: 'refine' | 'revise'; + concern: ConditionConcern; + } | null>(null); + + const codetypeToSystem: Record = { + ICD10: 'ICD-10-CM', + 'SNOMED US': 'SNOMED', + }; + + /** Refine/revise: append the new assertion and point the visit item at it. */ + const handleEditorSave = (draft: ConditionAssertionDraft) => { + const target = editor?.concern; + if (!target) return; + const newAssertion: ConditionAssertion = { + ...draft, + id: `A-${Date.now()}`, + date: new Date().toISOString().slice(0, 10), + }; + setConcernList((prev) => + prev.map((c) => { + if (c.concernId !== target.concernId) return c; + const assertions = + draft.changeType === 'revision' + ? c.assertions.map((a) => + a.id === draft.supersedes + ? { ...a, verificationStatus: 'refuted' as const } + : a + ) + : c.assertions; + return { ...c, assertions: [...assertions, newAssertion] }; + }) + ); + setItems((prev) => + prev.map((i) => + i.concernId === target.concernId + ? { ...i, assertionId: newAssertion.id } + : i + ) + ); + }; + + return ( +
+ { + const concernId = `C-${Date.now()}`; + const assertionId = `A-${Date.now()}`; + setConcernList((prev) => [ + ...prev, + { + concernId, + clinicalStatus: 'active', + assertions: [ + { + id: assertionId, + date: new Date().toISOString().slice(0, 10), + text: pick.label, + // free text arrives uncoded and unconfirmed + verificationStatus: pick.code ? 'confirmed' : 'unconfirmed', + coding: pick.code + ? [ + { + system: + codetypeToSystem[pick.code.codetype] ?? + pick.code.codetype, + code: pick.code.fullcode, + primary: true, + }, + ] + : [], + }, + ], + }, + ]); + setItems((prev) => [...prev, { concernId, assertionId }]); + }} + onReorderItems={(ids) => + setItems((prev) => + [...prev].sort( + (a, b) => ids.indexOf(a.concernId) - ids.indexOf(b.concernId) + ) + ) + } + onReorderOrders={(ids) => + setOrders((prev) => + [...prev].sort( + (a, b) => ids.indexOf(a.orderId) - ids.indexOf(b.orderId) + ) + ) + } + onAddOrder={(item, order) => + setOrders((prev) => [ + ...prev, + { + orderId: `O-${Date.now()}`, + type: order.type, + display: order.display, + detail: order.code + ? `${order.code.codetype} ${order.code.fullcode}` + : undefined, + code: order.code, + concernId: item?.concernId, // null item = unlinked order + }, + ]) + } + renderOrderSearch={({ domains, placeholder, onPick, onFreeText }) => ( + + )} + onLinkOrder={(order, concernId) => + setOrders((prev) => + prev.map((o) => + o.orderId === order.orderId ? { ...o, concernId } : o + ) + ) + } + onAction={(item, action) => { + if (action === 'refine' || action === 'revise') { + const concern = concernList.find( + (c) => c.concernId === item.concernId + ); + if (concern) setEditor({ mode: action, concern }); + } + }} + /> + !open && setEditor(null)} + concern={editor?.concern} + onSave={handleEditorSave} + /> +
+ ); +} + +/** + * Assessment & Plan with orders nested under each problem via the durable + * concern link. Hover a problem and use the **+** action to add an order + * inline. Drag a problem block to re-sequence the assessment; drag an order + * to reorder it within its plan β€” or drop it onto another problem (or between + * its orders) to move it there. The TSH and chest X-ray orders arrive + * unlinked β€” drag them onto a problem or use the chips in the amber bucket. + */ +export const Interactive: Story = { + render: () => , +}; + +/** Assessment only β€” plan hidden. */ +export const AssessmentOnly: Story = { + args: { + concerns, + items: sampleItems, + orders: sampleOrders, + showPlan: false, + readOnly: true, + title: 'Assessment', + }, + render: (args) => ( +
+ +
+ ), +}; + +/** Display-only A&P, e.g. for a signed note. */ +export const ReadOnly: Story = { + args: { + concerns, + items: sampleItems, + orders: sampleOrders.filter((o) => o.concernId), + readOnly: true, + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/components/Assessment/Assessment.tsx b/src/components/Assessment/Assessment.tsx new file mode 100644 index 00000000..8530ca7b --- /dev/null +++ b/src/components/Assessment/Assessment.tsx @@ -0,0 +1,1162 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '../../utils/cn'; +import { Badge } from '../Badge/Badge'; +import { Button } from '../Button'; +import { Tooltip } from '../Tooltip'; +import { Card, CardHeader, CardContent } from '../Card/Card'; +import { + PencilIcon, + RefreshIcon, + LinkIcon, + PlusIcon, + PillIcon, + TestTubeIcon, + ScanIcon, + StethoscopeIcon, + SendIcon, + AlertCircleIcon, + GripVerticalIcon, + ChevronUpIcon, + ChevronDownIcon, + MoreVerticalIcon, +} from '../Icons'; +import { Dropdown, DropdownItem, DropdownSeparator } from '../Dropdown'; +import { + CodingChips, + toolbarKeyNav, + type ConditionAssertion, + type ConditionConcern, +} from '../ProblemList'; +import { + useDragReorder, + dragIndicatorClasses, + reorderIds, +} from '../../hooks/useDragReorder'; +import { useLiveAnnouncement } from '../../hooks/useLiveAnnouncement'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Order categories that can nest under an assessed problem. */ +export type OrderType = + | 'medication' + | 'lab' + | 'imaging' + | 'procedure' + | 'referral'; + +/** + * An order placed this visit. Links to a problem via concernId (durable + * IndicationLink) β€” orders without a concernId collect in the unlinked bucket. + */ +export interface AssessmentOrder { + orderId: string; + type: OrderType; + /** Display text, e.g. "insulin glargine 10 units qhs" */ + display: string; + /** Durable link to the problem this order is for */ + concernId?: string; + /** Secondary display text, e.g. status or sig */ + detail?: string; + /** Coding from the code lookup, when the order was picked from it */ + code?: { fullid: string; codetype: string; fullcode: string }; +} + +/** A code picked from a lookup (structurally matches CodifyResult). */ +export interface OrderCodePick { + fullid: string; + label: string; + codetype: string; + fullcode: string; +} + +/** What the unified add row hands back: a coded pick or free text. */ +export interface AssessmentAddPick { + label: string; + /** Absent for free-text entries */ + code?: { fullid: string; codetype: string; fullcode: string }; +} + +/** Infer the order type from the picked code's coding system. */ +export function orderTypeForCodetype(codetype: string): OrderType { + const ct = codetype.toUpperCase(); + if (['RXNORM', 'FDB', 'FDB MEDNAME', 'NDC', 'CVX'].includes(ct)) { + return 'medication'; + } + if (ct.startsWith('LOINC') || ct.endsWith('ORDER')) return 'lab'; + return 'procedure'; // HCPCS, ICD10PCS, … (imaging is a manual choice) +} + +/** Coding systems that represent a diagnosis/problem rather than an order. */ +export function isConditionCodetype(codetype: string): boolean { + const ct = codetype.toUpperCase(); + return ct === 'ICD10' || ct === 'ICD9' || ct === 'SNOMED US'; +} + +/** Code-lookup domains to search for an order-type filter. */ +export const ORDER_TYPE_SEARCH_DOMAINS: Record = { + medication: ['med', 'vaccine'], + lab: ['lab'], + imaging: ['procedure'], + procedure: ['procedure'], + referral: ['procedure'], +}; + +/** Context-specific search placeholder per order-type filter. */ +const ORDER_TYPE_PLACEHOLDERS: Record<'auto' | OrderType, string> = { + auto: 'Search medications, labs, imaging, procedures…', + medication: 'Search medications… (try "lasix")', + lab: 'Search labs… (try "a1c")', + imaging: 'Search imaging… (try "chest x")', + procedure: 'Search procedures…', + referral: 'Search referrals & consults…', +}; + +/** One assessed problem this visit. */ +export interface AssessmentItem { + concernId: string; + /** Today's assertion for the concern (even a no-change "addressed today" one) */ + assertionId: string; + note?: string; +} + +/** Row actions on an assessed problem. */ +export type AssessmentAction = 'refine' | 'revise' | 'add-order'; + +export interface AssessmentProps extends Omit< + React.HTMLAttributes, + 'className' | 'title' +> { + /** Concerns addressed this visit (with their assertion history) */ + concerns: ConditionConcern[]; + /** Assessment entries β€” one per assessed concern (controlled) */ + items: AssessmentItem[]; + /** Orders placed this visit */ + orders?: AssessmentOrder[]; + /** Header title (pass null to hide) */ + title?: string | null; + /** Show the plan (orders nested under each problem). Default true. */ + showPlan?: boolean; + /** Called when the plan toggle changes (omit to hide the toggle) */ + onShowPlanChange?: (show: boolean) => void; + /** Called when a row action is clicked */ + onAction?: (item: AssessmentItem, action: AssessmentAction) => void; + /** + * Called when a new order is created from the inline order form. + * Enables the "Add order" affordances β€” `item` is null when the order is + * added from the unlinked bucket. The new order should come back in via + * `orders` (with `concernId` set to the item's concern when linked). + */ + onAddOrder?: ( + item: AssessmentItem | null, + order: { type: OrderType; display: string; code?: AssessmentOrder['code'] } + ) => void; + /** + * Called when a diagnosis (coded or free text) is added as a new problem + * via the unified add row. Enables it together with renderOrderSearch. + */ + onAddAssessment?: (pick: AssessmentAddPick) => void; + /** + * Render a code-lookup search for the add-order / add-problem forms + * (dependency-injected so the library build doesn't bundle the lookup's + * worker β€” pass e.g. a CodeLookup wired to your index). `domains` reflects + * the user's order-type filter ('auto' = undefined = search everything) and + * `placeholder` is context-specific; call `onPick` with the chosen code. + * Omit to fall back to free-text order entry. + */ + renderOrderSearch?: (args: { + domains?: string[]; + placeholder: string; + onPick: (pick: OrderCodePick) => void; + /** Present when free-text entry is supported in this context */ + onFreeText?: (text: string) => void; + }) => React.ReactNode; + /** Called when an unlinked order is linked to a problem β€” also used when an + * order is dragged onto another problem (re-link = move) */ + onLinkOrder?: (order: AssessmentOrder, concernId: string) => void; + /** + * Called with the full new order-id sequence after an order is dragged to a + * new position β€” enables drag & drop reordering of orders within a plan. + * Orders render in `orders` array order per problem; persist and feed back. + */ + onReorderOrders?: (orderIds: string[]) => void; + /** + * Called with the new concernId order after a problem block is dragged β€” + * enables drag & drop reordering of the assessment. The list renders in + * `items` order; persist and feed the order back in. + */ + onReorderItems?: (concernIds: string[]) => void; + /** Hide all controls (display only) */ + readOnly?: boolean; + /** Additional CSS classes */ + className?: string; + /** Test ID for testing */ + 'data-testid'?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +export const ORDER_TYPE_META: Record< + OrderType, + { label: string; icon: React.ComponentType<{ size?: number | string }> } +> = { + medication: { label: 'Medication', icon: PillIcon }, + lab: { label: 'Lab', icon: TestTubeIcon }, + imaging: { label: 'Imaging', icon: ScanIcon }, + procedure: { label: 'Procedure', icon: StethoscopeIcon }, + referral: { label: 'Referral', icon: SendIcon }, +}; + +const ACTION_META: Record< + AssessmentAction, + { label: string; icon: React.ComponentType<{ size?: number | string }> } +> = { + refine: { label: 'Refine (more specific)', icon: PencilIcon }, + revise: { label: 'Revise (prior was wrong)', icon: RefreshIcon }, + 'add-order': { label: 'Add order', icon: PlusIcon }, +}; + +// ============================================================================= +// Sub-components +// ============================================================================= + +function OrderContent({ order }: { order: AssessmentOrder }) { + const meta = ORDER_TYPE_META[order.type]; + const Icon = meta.icon; + return ( + + + + + {order.display} + {order.detail && {order.detail}} + + ); +} + +/** Drag state shared by the order rows and the problem-block drop targets. */ +interface OrderDrag { + draggingId: string | null; + over: + | { type: 'order'; id: string; after: boolean } + | { type: 'concern'; concernId: string } + | null; + rowProps: (order: AssessmentOrder) => React.HTMLAttributes; + enabled: boolean; +} + +/** Keyboard + menu controls for an order row (the non-pointer path for DnD). */ +interface OrderControls { + /** Reorder within the current plan (Alt+↑/↓, menu) */ + moveWithin?: (order: AssessmentOrder, dir: -1 | 1) => void; + /** Re-link to another problem (Alt+←/β†’, menu) */ + moveToProblem?: (order: AssessmentOrder, concernId: string) => void; + /** The problem before/after the order's current one, if any */ + adjacentProblem: ( + order: AssessmentOrder, + dir: -1 | 1 + ) => { concernId: string; label: string } | null; + /** All problems (for the Move to… menu) */ + targets: { concernId: string; label: string }[]; +} + +function OrderRow({ + order, + drag, + controls, +}: { + order: AssessmentOrder; + drag: OrderDrag; + controls?: OrderControls; +}) { + const interactive = Boolean(controls?.moveWithin || controls?.moveToProblem); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget || !controls) return; + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + controls.moveWithin?.(order, e.key === 'ArrowUp' ? -1 : 1); + } else if (e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + e.preventDefault(); + const adj = controls.adjacentProblem( + order, + e.key === 'ArrowLeft' ? -1 : 1 + ); + if (adj) controls.moveToProblem?.(order, adj.concernId); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + // Roving focus between order rows in the same plan + e.preventDefault(); + const list = e.currentTarget.closest('ul'); + if (!list) return; + const rows = Array.from( + list.querySelectorAll('li[data-order-id]') + ); + const i = rows.indexOf(e.currentTarget as HTMLElement); + rows[ + e.key === 'ArrowUp' + ? Math.max(0, i - 1) + : Math.min(rows.length - 1, i + 1) + ]?.focus(); + } + }; + + return ( + // Order rows are focus stops so the drag interactions have a full keyboard + // equivalent: Alt+↑/↓ reorders, Alt+←/β†’ moves between problems (508). + /* eslint-disable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */ +
  • + {/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/no-noninteractive-tabindex */} + {drag.enabled && ( + + )} + + {interactive && ( + + + + + } + placement="bottom-end" + > + {controls?.moveWithin && ( + <> + } + onClick={() => controls.moveWithin?.(order, -1)} + > + Move up + + } + onClick={() => controls.moveWithin?.(order, 1)} + > + Move down + + + )} + {controls?.moveToProblem && + controls.targets.some((t) => t.concernId !== order.concernId) && ( + <> + {controls.moveWithin && } + {controls.targets + .filter((t) => t.concernId !== order.concernId) + .map((t) => ( + } + onClick={() => + controls.moveToProblem?.(order, t.concernId) + } + > + Move to: {t.label} + + ))} + + )} + + + )} +
  • + ); +} + +/** Inline per-problem order entry: type filter + code lookup (or free text). */ +function AddOrderForm({ + problemText, + onSubmit, + onCancel, + renderSearch, +}: { + problemText: string; + onSubmit: (order: { + type: OrderType; + display: string; + code?: AssessmentOrder['code']; + }) => void; + onCancel: () => void; + renderSearch?: AssessmentProps['renderOrderSearch']; +}) { + const [type, setType] = React.useState<'auto' | OrderType>( + renderSearch ? 'auto' : 'medication' + ); + const [display, setDisplay] = React.useState(''); + const inputRef = React.useRef(null); + + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const submit = () => { + const text = display.trim(); + if (!text) return; + onSubmit({ type: type === 'auto' ? 'procedure' : type, display: text }); + setDisplay(''); + inputRef.current?.focus(); + }; + + const handlePick = (pick: OrderCodePick) => { + onSubmit({ + type: type === 'auto' ? orderTypeForCodetype(pick.codetype) : type, + display: pick.label, + code: { + fullid: pick.fullid, + codetype: pick.codetype, + fullcode: pick.fullcode, + }, + }); + }; + + return ( +
    + + + {renderSearch ? ( +
    + {renderSearch({ + domains: + type === 'auto' ? undefined : ORDER_TYPE_SEARCH_DOMAINS[type], + placeholder: ORDER_TYPE_PLACEHOLDERS[type], + onPick: handlePick, + })} +
    + ) : ( + <> + setDisplay(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submit(); + else if (e.key === 'Escape') onCancel(); + }} + placeholder="e.g. lisinopril 10 mg tablet β€” 1 po daily" + aria-label="Order description" + className={cn( + 'border-border bg-background text-foreground placeholder:text-muted-foreground', + 'h-8 min-w-48 flex-1 rounded-md border px-2.5 text-sm', + 'focus:ring-ring focus:ring-2 focus:outline-none' + )} + /> + + + )} + +
    + ); +} + +// ============================================================================= +// Assessment +// ============================================================================= + +/** + * Visit-specific Assessment (& Plan). One block per assessed problem showing + * today's assertion; with the plan enabled, orders linked to the problem (via + * concernId β€” the durable IndicationLink) render indented under it. Orders + * without a problem link collect in an unlinked bucket with a one-click link + * affordance. + * + * Controlled component: concerns, items and orders come in via props; every + * user interaction is reported through callbacks. + * + * @example + * ```tsx + * handleAction(item, action)} + * onLinkOrder={(order, concernId) => linkOrder(order.orderId, concernId)} + * /> + * ``` + */ +export const Assessment = React.forwardRef( + ( + { + concerns, + items, + orders = [], + title = 'Assessment & plan', + showPlan = true, + onShowPlanChange, + onAction, + onAddOrder, + onAddAssessment, + onLinkOrder, + onReorderItems, + onReorderOrders, + renderOrderSearch, + readOnly = false, + className, + 'data-testid': dataTestId, + ...props + }, + ref + ) => { + const [addingFor, setAddingFor] = React.useState(null); + const [addMode, setAddMode] = React.useState<'auto' | 'problem' | 'order'>( + 'auto' + ); + /** Free text typed in auto mode β€” we must ask what it is before adding */ + const [pendingFreeText, setPendingFreeText] = React.useState( + null + ); + // clears-then-sets so repeated identical messages re-announce + const [announcement, setAnnouncement] = useLiveAnnouncement(); + + const concernById = React.useMemo( + () => new Map(concerns.map((c) => [c.concernId, c])), + [concerns] + ); + + const assertionOf = ( + item: AssessmentItem + ): ConditionAssertion | undefined => + concernById + .get(item.concernId) + ?.assertions.find((a) => a.id === item.assertionId); + + const unlinkedOrders = orders.filter( + (o) => !o.concernId || !items.some((i) => i.concernId === o.concernId) + ); + + const drag = useDragReorder({ + ids: items.map((i) => i.concernId), + onReorder: + readOnly || !onReorderItems + ? undefined + : (ids) => { + setAnnouncement('Assessment problems reordered'); + onReorderItems(ids); + }, + }); + + // ---- Keyboard + menu equivalents for order drag & drop (508) ---- + const problemLabel = React.useCallback( + (concernId: string) => + concernById + .get(concernId) + ?.assertions.find((a) => + items.some( + (i) => i.concernId === concernId && i.assertionId === a.id + ) + )?.text ?? concernId, + [concernById, items] + ); + + const orderControls: OrderControls | undefined = readOnly + ? undefined + : { + moveWithin: onReorderOrders + ? (order, dir) => { + const planIds = orders + .filter((o) => o.concernId === order.concernId) + .map((o) => o.orderId); + const i = planIds.indexOf(order.orderId); + const target = planIds[i + dir]; + if (!target) return; + onReorderOrders( + reorderIds( + orders.map((o) => o.orderId), + order.orderId, + target, + dir === 1 + ) + ); + setAnnouncement( + `${order.display} moved ${dir === -1 ? 'up' : 'down'}` + ); + } + : undefined, + moveToProblem: onLinkOrder + ? (order, concernId) => { + onLinkOrder(order, concernId); + setAnnouncement( + `${order.display} moved to ${problemLabel(concernId)}` + ); + } + : undefined, + adjacentProblem: (order, dir) => { + const i = items.findIndex((it) => it.concernId === order.concernId); + const adj = items[i + dir]; + return adj + ? { concernId: adj.concernId, label: problemLabel(adj.concernId) } + : null; + }, + targets: items.map((it) => ({ + concernId: it.concernId, + label: problemLabel(it.concernId), + })), + }; + + // ---- Order-level drag & drop: reorder within a plan, or move to another + // problem by dropping on its block (re-link) or between its orders. ---- + const orderDragEnabled = + !readOnly && Boolean(onReorderOrders || onLinkOrder); + const [draggingOrderId, setDraggingOrderId] = React.useState( + null + ); + const [orderOver, setOrderOver] = React.useState(null); + + const resetOrderDrag = () => { + setDraggingOrderId(null); + setOrderOver(null); + }; + + /** Drop `dragged` relative to `target` (another order row). */ + const dropOnOrder = ( + dragged: AssessmentOrder, + target: AssessmentOrder, + after: boolean + ) => { + if (dragged.concernId !== target.concernId && target.concernId) { + onLinkOrder?.(dragged, target.concernId); // move to the other problem + setAnnouncement( + `${dragged.display} moved to ${problemLabel(target.concernId)}` + ); + } else { + setAnnouncement(`${dragged.display} reordered`); + } + onReorderOrders?.( + reorderIds( + orders.map((o) => o.orderId), + dragged.orderId, + target.orderId, + after + ) + ); + }; + + const orderDrag: OrderDrag = { + enabled: orderDragEnabled, + draggingId: draggingOrderId, + over: orderOver, + rowProps: (order) => + orderDragEnabled + ? { + draggable: true, + onDragStart: (e) => { + e.stopPropagation(); // don't start a problem-block drag + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', order.orderId); + setDraggingOrderId(order.orderId); + }, + onDragEnd: (e) => { + e.stopPropagation(); + resetOrderDrag(); + }, + onDragOver: (e) => { + if (!draggingOrderId || draggingOrderId === order.orderId) + return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const r = e.currentTarget.getBoundingClientRect(); + const after = e.clientY > r.top + r.height / 2; + setOrderOver({ type: 'order', id: order.orderId, after }); + }, + onDrop: (e) => { + const draggedId = + draggingOrderId ?? e.dataTransfer.getData('text/plain'); + const dragged = orders.find((o) => o.orderId === draggedId); + if (!dragged || dragged.orderId === order.orderId) return; + e.preventDefault(); + e.stopPropagation(); + const r = e.currentTarget.getBoundingClientRect(); + const after = e.clientY > r.top + r.height / 2; + dropOnOrder(dragged, order, after); + resetOrderDrag(); + }, + } + : {}, + }; + + /** Problem blocks double as drop targets for orders (move-to-problem). */ + const blockProps = ( + concernId: string + ): React.HTMLAttributes => { + const itemProps = drag.rowProps(concernId); + if (!orderDragEnabled) return itemProps; + return { + ...itemProps, + onDragOver: (e) => { + if (draggingOrderId) { + const dragged = orders.find((o) => o.orderId === draggingOrderId); + if (dragged?.concernId === concernId) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setOrderOver({ type: 'concern', concernId }); + return; + } + itemProps.onDragOver?.(e); + }, + onDragLeave: (e) => { + if (draggingOrderId) { + setOrderOver((prev) => + prev?.type === 'concern' && prev.concernId === concernId + ? null + : prev + ); + return; + } + itemProps.onDragLeave?.(e); + }, + onDrop: (e) => { + // An order drop on the block moves the order to this problem; any + // other payload (a problem-block drag) falls through to item reorder. + const payload = + draggingOrderId ?? e.dataTransfer.getData('text/plain'); + const dragged = orders.find((o) => o.orderId === payload); + if (dragged) { + e.preventDefault(); + if (dragged.concernId !== concernId) { + onLinkOrder?.(dragged, concernId); + setAnnouncement( + `${dragged.display} moved to ${problemLabel(concernId)}` + ); + } + resetOrderDrag(); + return; + } + itemProps.onDrop?.(e); + }, + }; + }; + + return ( + + {title !== null && ( + +

    + {title} +

    + {onShowPlanChange && ( + + )} +
    + )} + + + {items.length === 0 && ( +

    + No problems assessed this visit. +

    + )} + +
      + {items.map((item, index) => { + const assertion = assertionOf(item); + if (!assertion) return null; + const linkedOrders = orders.filter( + (o) => o.concernId === item.concernId + ); + const bp = blockProps(item.concernId); + const formOpen = addingFor === item.concernId; + return ( +
    1. +
      + {drag.enabled && ( + + )} + + {index + 1}. + + + {assertion.text} + + {assertion.verificationStatus !== 'confirmed' && ( + + {assertion.verificationStatus} + + )} + + + {!readOnly && ( +
      + {(Object.keys(ACTION_META) as AssessmentAction[]).map( + (action) => { + const meta = ACTION_META[action]; + const Icon = meta.icon; + if ( + action === 'add-order' && + !onAddOrder && + !onAction + ) + return null; + return ( + + + + ); + } + )} +
      + )} +
      + + {item.note && ( +

      + {item.note} +

      + )} + + {showPlan && linkedOrders.length > 0 && ( +
        + {linkedOrders.map((order) => ( + + ))} +
      + )} + + {!readOnly && onAddOrder && addingFor === item.concernId && ( + onAddOrder(item, order)} + onCancel={() => setAddingFor(null)} + renderSearch={renderOrderSearch} + /> + )} +
    2. + ); + })} +
    + + {/* Unified add row: a dx pick adds a problem, anything else adds an + (unlinked) order β€” auto-detected from the coding system, or + forced via the mode dropdown. Free text asks (in auto mode). */} + {!readOnly && + renderOrderSearch && + (onAddAssessment || onAddOrder) && ( +
    + +
    + {renderOrderSearch({ + domains: + addMode === 'problem' + ? ['condition'] + : addMode === 'order' + ? ['med', 'lab', 'procedure', 'vaccine'] + : undefined, // auto: everything β€” the pick decides + placeholder: + addMode === 'problem' + ? 'Search diagnoses… (try "chf" or "atrial fib")' + : addMode === 'order' + ? 'Search medications, labs, imaging, procedures…' + : 'Add problem or order… (a diagnosis becomes a problem; meds, labs & imaging become orders)', + onPick: (pick) => { + const asProblem = + addMode === 'problem' || + (addMode === 'auto' && + isConditionCodetype(pick.codetype) && + Boolean(onAddAssessment)); + if (asProblem) { + onAddAssessment?.({ + label: pick.label, + code: { + fullid: pick.fullid, + codetype: pick.codetype, + fullcode: pick.fullcode, + }, + }); + } else { + onAddOrder?.(null, { + type: orderTypeForCodetype(pick.codetype), + display: pick.label, + code: { + fullid: pick.fullid, + codetype: pick.codetype, + fullcode: pick.fullcode, + }, + }); + } + }, + onFreeText: (text) => { + if (addMode === 'problem') { + onAddAssessment?.({ label: text }); + } else if (addMode === 'order') { + onAddOrder?.(null, { + type: 'procedure', + display: text, + }); + } else { + setPendingFreeText(text); // auto: we have to ask + } + }, + })} +
    + + {/* Free text in auto mode: ask what it is */} + {pendingFreeText !== null && ( +
    + + Add{' '} + + β€œ{pendingFreeText}” + {' '} + as: + + {onAddAssessment && ( + + )} + {onAddOrder && ( + + )} + +
    + )} +
    + )} + + {/* Unlinked orders bucket β€” only takes space once something is in it */} + {showPlan && unlinkedOrders.length > 0 && ( +
    +

    + + Orders not linked to a problem +

    +
      + {unlinkedOrders.map((order) => ( + // Same row component as linked orders: focusable, Alt+arrow + // keyboard moves, and the Move menu (508 parity with drag) + + ))} +
    +
    + )} +
    + + {/* Move/reorder announcements for screen readers */} +
    + {announcement} +
    +
    + ); + } +); + +Assessment.displayName = 'Assessment'; + +export default Assessment; diff --git a/src/components/Assessment/index.ts b/src/components/Assessment/index.ts new file mode 100644 index 00000000..437d82ac --- /dev/null +++ b/src/components/Assessment/index.ts @@ -0,0 +1,14 @@ +export { + Assessment, + ORDER_TYPE_META, + ORDER_TYPE_SEARCH_DOMAINS, + orderTypeForCodetype, + isConditionCodetype, + type AssessmentProps, + type AssessmentItem, + type AssessmentOrder, + type AssessmentAction, + type OrderType, + type OrderCodePick, + type AssessmentAddPick, +} from './Assessment'; diff --git a/src/components/CodeLookup/CodeLookup.stories.tsx b/src/components/CodeLookup/CodeLookup.stories.tsx new file mode 100644 index 00000000..8bb2cab0 --- /dev/null +++ b/src/components/CodeLookup/CodeLookup.stories.tsx @@ -0,0 +1,97 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { CodeLookup } from './CodeLookup'; +import type { CodifyResult } from './engine'; + +const meta: Meta = { + title: 'Healthcare/CodeLookup', + component: CodeLookup, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +**Offline medical-code autocomplete (proof of concept)** over the full MedicalCodify_search +dataset (~770K entries: ICD-10, SNOMED, RxNorm, FDB, LOINC, HCPCS, ICD-10-PCS, CVX, +Quest/LabCorp orders). + +- Pre-built binary index shards are fetched once and searched **entirely in a Web Worker** β€” + no server round-trips per keystroke, works offline. +- **Every token is a word prefix**: \`con hea fa\` β†’ *Congestive heart failure*. +- **Aliases** are indexed on the documents at build time: \`chf\`, \`lvhf\`, \`lasix\` ↔ + \`furosemide\`, \`a1c\` ↔ \`hba1c\`, \`tylenol\` ↔ \`acetaminophen\`… +- **Typo fallback**: a token that matches nothing retries with edit-distance-1 candidates + (\`congestve\`, \`furosemid\`). +- **Usage priors**: frequently used codes (top-200 meds/diagnoses/procedures sample) rank + above rare ones with equal text relevance. +- **Locales**: shards are built per locale under \`/codify/{locale}/\`; use the 🌐 Language + toolbar to switch. The \`es\` set is a curated sample (common diagnoses + med ingredients β€” + try *insuficiencia card*, *hta*, *paracetamol*). +- **OPFS persistence**: shards are cached in the browser's origin-private file system and + refetched only when the served manifest changes. + +Shards are committed via git-lfs and served from \`.storybook/public/codify/{locale}/\`; +rebuild with \`pnpm codify:build\` (pipeline lives in the \`packages/codify\` submodule). + +πŸ“– Full architecture documentation β€” build pipeline, .mcdx binary format, scoring, priors, +aliases, locales, drill-down β€” in [src/components/CodeLookup/README.md](https://github.com/mieweb/ui/blob/healthcare-clinical-components/src/components/CodeLookup/README.md). + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +function Template({ + domains, + locale, +}: { + domains?: ('condition' | 'med' | 'lab' | 'procedure' | 'vaccine')[]; + locale?: string; +}) { + const [selected, setSelected] = useState(null); + return ( +
    + + {selected && ( +
    +          {JSON.stringify(selected, null, 2)}
    +        
    + )} +
    + ); +} + +/** All domains (~81 MB of shards β€” loads in the background, then searches in ms). */ +export const AllDomains: Story = { + render: (_args, { globals }) =>