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..3a7c23ae 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ 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/ +# The codify build pipeline lives in a separate (not-yet-published) repo and is +# kept locally here without being tracked in this repo. +packages/codify/ diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 164225a9..d27a5a1f 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -14,6 +14,8 @@ import { ozwellBrand } from '../src/brands/ozwell'; import { wagglelineBrand } from '../src/brands/waggleline'; import { webchartBrand } from '../src/brands/webchart'; import type { BrandConfig } from '../src/brands/types'; +import { CodeLookup } from '../src/components/CodeLookup'; +import { CodeLookupProvider } from '../src/components/CodeLookup/context'; // Map of available brands const brands: Record = { @@ -243,11 +245,25 @@ const withBrand: Decorator = (Story, context) => { ); }; +// Provides an ambient CodeLookup so the healthcare components' default (no +// explicit `codeLookup` / `renderCodeSearch` prop) demonstrates offline coded +// search. Stories that inject their own config still win (explicit overrides +// context); pass `codeLookup={false}` in a story to demo the plain-text opt-out. +const withCodeLookup: Decorator = (Story, context) => { + const locale = (context.globals.locale as string) || 'en'; + return ( + + + + ); +}; + const preview: Preview = { initialGlobals: { brand: 'bluehive', theme: 'light', density: 'standard', + locale: 'en', }, globalTypes: { brand: { @@ -292,6 +308,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: { @@ -345,7 +373,7 @@ const preview: Preview = { }, }, }, - decorators: [withGitHubSource, withBrand], + decorators: [withGitHubSource, withBrand, withCodeLookup], }; export default preview; diff --git a/.storybook/public/codify/en/condition.mcdx b/.storybook/public/codify/en/condition.mcdx new file mode 100644 index 00000000..9bad97d1 --- /dev/null +++ b/.storybook/public/codify/en/condition.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f09e8fc618b6fbbef5651cebc4e5fea1b040ef4b3f18d719dff75c87ee352b6 +size 4289695 diff --git a/.storybook/public/codify/en/lab.mcdx b/.storybook/public/codify/en/lab.mcdx new file mode 100644 index 00000000..c4d4c1be --- /dev/null +++ b/.storybook/public/codify/en/lab.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80690c87bdc34da270ac76915b399bebc48261a769035408d6dcab9de3c0ff27 +size 8820150 diff --git a/.storybook/public/codify/en/manifest.json b/.storybook/public/codify/en/manifest.json new file mode 100644 index 00000000..077d9bda --- /dev/null +++ b/.storybook/public/codify/en/manifest.json @@ -0,0 +1,56 @@ +{ + "version": 2, + "locale": "en", + "builtAt": "2026-07-04T16:04:51.814Z", + "shards": [ + { + "domain": "condition", + "file": "condition.mcdx", + "bytes": 4289695, + "docCount": 103088, + "tokenCount": 8728 + }, + { + "domain": "med", + "file": "med.mcdx", + "bytes": 15301251, + "docCount": 381526, + "tokenCount": 40153 + }, + { + "domain": "lab", + "file": "lab.mcdx", + "bytes": 8820150, + "docCount": 185261, + "tokenCount": 22914 + }, + { + "domain": "procedure", + "file": "procedure.mcdx", + "bytes": 4134391, + "docCount": 99551, + "tokenCount": 9810 + }, + { + "domain": "vaccine", + "file": "vaccine.mcdx", + "bytes": 23054, + "docCount": 608, + "tokenCount": 489 + }, + { + "domain": "occupational", + "file": "occupational.mcdx", + "bytes": 2508, + "docCount": 29, + "tokenCount": 99 + }, + { + "domain": "quality", + "file": "quality.mcdx", + "bytes": 827, + "docCount": 8, + "tokenCount": 27 + } + ] +} \ 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..268b56d6 --- /dev/null +++ b/.storybook/public/codify/en/med.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a512b85203cdd23caa8e40d188cb74161735380bbb8f17c94988210b84bbb59a +size 15301251 diff --git a/.storybook/public/codify/en/occupational.mcdx b/.storybook/public/codify/en/occupational.mcdx new file mode 100644 index 00000000..b1be0be8 --- /dev/null +++ b/.storybook/public/codify/en/occupational.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8fcd23161bdede9c460855171142751f9acbea63398e1dbe5e4014f87a3b4b2 +size 2508 diff --git a/.storybook/public/codify/en/procedure.mcdx b/.storybook/public/codify/en/procedure.mcdx new file mode 100644 index 00000000..374e46a0 --- /dev/null +++ b/.storybook/public/codify/en/procedure.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4091342db1048444aaaa52c58fb52f7eb22c5e73462778a6c52fa1f31232fbc +size 4134391 diff --git a/.storybook/public/codify/en/programs.json b/.storybook/public/codify/en/programs.json new file mode 100644 index 00000000..9a7fd634 --- /dev/null +++ b/.storybook/public/codify/en/programs.json @@ -0,0 +1,270 @@ +{ + "comment": "Health-surveillance program metadata. Keys are CODETYPE|FULLCODE of entries in the occupational (and later quality) shards. `orders` reference entries in the regular shards by CODETYPE|FULLCODE and are resolved at runtime for the drill-down / due engine. `kind`: surveillance (exposure monitoring) | fitness (fitness-for-duty qualification) | credential (certification exam) | quality (eCQM). `periodicityMonths` omitted = one-time / event-driven. `ageMin`/`ageMax`/`sex` gate applicability. Deployments can serve their own programs.json (employer-specific protocols) via CodeLookup's programsUrl prop. Supersedes order-sets.json (worker still falls back).", + "programs": { + "OSHA|1910.95": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + { "alt": ["HCPCS|92551", "HCPCS|0209T", "LOINC|89015-2"] } + ] + }, + "OSHA|1910.134": { + "kind": "fitness", + "orders": [ + { "alt": ["HCPCS|94150", "HCPCS|94014"] }, + "HCPCS|71020", + { + "key": "LOINC|85216-0", + "after": ["HCPCS|94150", "HCPCS|71020"] + } + ] + }, + "OSHA|1910.1025": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "Quest Order|3058", + "Quest Order|22996", + "Quest Order|948", + "LabCorp Order|005009", + "LabCorp Order|003772" + ] + }, + "OSHA|1926.62": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "Quest Order|3058", + "Quest Order|22996", + "Quest Order|948", + "LabCorp Order|005009" + ] + }, + "OSHA|1910.1001": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|71020", "HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1053": { + "kind": "surveillance", + "periodicityMonths": 36, + "orders": ["HCPCS|71020", "HCPCS|94150", "LabCorp Order|182873"] + }, + "OSHA|1910.1028": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009", "LabCorp Order|007732"] + }, + "OSHA|1910.1027": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + "LabCorp Order|085340", + "LabCorp Order|072249", + "Quest Order|38994", + "Quest Order|4944" + ] + }, + "OSHA|1910.1026": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "OSHA|1910.1030": { + "kind": "surveillance", + "orders": [ + "CVX|45", + "CVX|189", + "Quest Order|499", + "Quest Order|26526" + ] + }, + "OSHA|1910.120": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + "LabCorp Order|005009", + "LabCorp Order|003772", + "HCPCS|94150", + "HCPCS|92551", + "HCPCS|99173" + ] + }, + "OSHA|1910.1048": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1052": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "OSHA|1910.1018": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "LabCorp Order|007039", + "LabCorp Order|007245", + "HCPCS|71020" + ] + }, + "OSHA|1910.1043": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1045": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009", "HCPCS|71020"] + }, + "OSHA|1910.1051": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "OSHA|1910.1096": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "FMCSA|391.41": { + "kind": "fitness", + "periodicityMonths": 24, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|003772"] + }, + "NFPA|1582": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": [ + "HCPCS|94150", + "HCPCS|92551", + "HCPCS|99173", + "LabCorp Order|005009", + "HCPCS|71020" + ] + }, + "FAA|67": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|003772"] + }, + "OPM|GS-1811": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": [ + "HCPCS|99173", + { "alt": ["HCPCS|92551", "HCPCS|0209T"] }, + "HCPCS|G0403", + "LabCorp Order|221010", + "LabCorp Order|790352", + { + "key": "LOINC|85216-0", + "after": [ + "HCPCS|99173", + "HCPCS|G0403", + "LabCorp Order|221010", + "LabCorp Order|790352" + ] + } + ] + }, + "USCG|46 CFR 10.302": { + "kind": "credential", + "periodicityMonths": 24, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|790352"] + }, + "MSHA|30 CFR 90": { + "kind": "surveillance", + "periodicityMonths": 60, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "NIOSH|CWHSP": { + "kind": "surveillance", + "periodicityMonths": 60, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "NRC|10 CFR 26": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["LabCorp Order|790352", "LabCorp Order|790390"] + }, + "DOE|10 CFR 712": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["LabCorp Order|790352", "HCPCS|G0403"] + }, + "USCIS|I-693": { + "kind": "credential", + "orders": [ + "Quest Order|19453", + "Quest Order|17389", + "Quest Order|11362", + "CVX|45" + ] + }, + "DOD|DoDI 6130.03": { + "kind": "fitness", + "orders": [ + "HCPCS|99173", + "HCPCS|92551", + "LabCorp Order|003772", + "LabCorp Order|790352" + ] + }, + "eCQM|CMS122": { + "kind": "quality", + "periodicityMonths": 12, + "ageMin": 18, + "ageMax": 75, + "orders": ["LabCorp Order|001453", "Quest Order|16320"] + }, + "eCQM|CMS124": { + "kind": "quality", + "periodicityMonths": 36, + "sex": "F", + "ageMin": 21, + "ageMax": 64, + "orders": ["LabCorp Order|009100"] + }, + "eCQM|CMS125": { + "kind": "quality", + "periodicityMonths": 24, + "sex": "F", + "ageMin": 40, + "ageMax": 74, + "orders": ["HCPCS|77057"] + }, + "eCQM|CMS130": { + "kind": "quality", + "periodicityMonths": 120, + "ageMin": 45, + "ageMax": 75, + "orders": [{ "alt": ["HCPCS|44388", "LabCorp Order|182949"] }] + }, + "eCQM|CMS117": { + "kind": "quality", + "ageMax": 2, + "orders": ["CVX|03", "CVX|120", "CVX|45"] + }, + "eCQM|CMS147": { + "kind": "quality", + "periodicityMonths": 12, + "orders": ["CVX|88"] + }, + "eCQM|CMS127": { + "kind": "quality", + "ageMin": 65, + "orders": ["CVX|133"] + }, + "eCQM|CMS153": { + "kind": "quality", + "periodicityMonths": 12, + "sex": "F", + "ageMin": 16, + "ageMax": 24, + "orders": ["Quest Order|11363"] + } + } +} diff --git a/.storybook/public/codify/en/quality.mcdx b/.storybook/public/codify/en/quality.mcdx new file mode 100644 index 00000000..6c7a6ccc --- /dev/null +++ b/.storybook/public/codify/en/quality.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0bdb28444dc77815234902002751161857bfdcdc83941c11354810e26a7689bc +size 827 diff --git a/.storybook/public/codify/en/vaccine.mcdx b/.storybook/public/codify/en/vaccine.mcdx new file mode 100644 index 00000000..abbda1cf --- /dev/null +++ b/.storybook/public/codify/en/vaccine.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87e6bcfdad9134d46274837af48e59a1b54134f2b14d1a02a1564fabde9476cc +size 23054 diff --git a/.storybook/public/codify/es/condition.mcdx b/.storybook/public/codify/es/condition.mcdx new file mode 100644 index 00000000..a43aeb02 --- /dev/null +++ b/.storybook/public/codify/es/condition.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a7c272f0ea3a260ba211cb8101740c75a557e93690aef72b697e813f5db9eac +size 10461 diff --git a/.storybook/public/codify/es/lab.mcdx b/.storybook/public/codify/es/lab.mcdx new file mode 100644 index 00000000..9d85a29e --- /dev/null +++ b/.storybook/public/codify/es/lab.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4dfc558ae082366e92e9f42b9af7ece0c266940201ed9f6bfad765500dd51a0 +size 4844 diff --git a/.storybook/public/codify/es/manifest.json b/.storybook/public/codify/es/manifest.json new file mode 100644 index 00000000..fbbe7b8d --- /dev/null +++ b/.storybook/public/codify/es/manifest.json @@ -0,0 +1,28 @@ +{ + "version": 2, + "locale": "es", + "builtAt": "2026-07-04T16:05:03.607Z", + "shards": [ + { + "domain": "condition", + "file": "condition.mcdx", + "bytes": 10461, + "docCount": 170, + "tokenCount": 369 + }, + { + "domain": "med", + "file": "med.mcdx", + "bytes": 8018, + "docCount": 269, + "tokenCount": 146 + }, + { + "domain": "lab", + "file": "lab.mcdx", + "bytes": 4844, + "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..20d7e06f --- /dev/null +++ b/.storybook/public/codify/es/med.mcdx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f2b57e0c1aa47b1472ba174bff4976af78b76b53a1a7663fea247e35aa9d39b +size 8018 diff --git a/.storybook/public/codify/es/programs.json b/.storybook/public/codify/es/programs.json new file mode 100644 index 00000000..9a7fd634 --- /dev/null +++ b/.storybook/public/codify/es/programs.json @@ -0,0 +1,270 @@ +{ + "comment": "Health-surveillance program metadata. Keys are CODETYPE|FULLCODE of entries in the occupational (and later quality) shards. `orders` reference entries in the regular shards by CODETYPE|FULLCODE and are resolved at runtime for the drill-down / due engine. `kind`: surveillance (exposure monitoring) | fitness (fitness-for-duty qualification) | credential (certification exam) | quality (eCQM). `periodicityMonths` omitted = one-time / event-driven. `ageMin`/`ageMax`/`sex` gate applicability. Deployments can serve their own programs.json (employer-specific protocols) via CodeLookup's programsUrl prop. Supersedes order-sets.json (worker still falls back).", + "programs": { + "OSHA|1910.95": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + { "alt": ["HCPCS|92551", "HCPCS|0209T", "LOINC|89015-2"] } + ] + }, + "OSHA|1910.134": { + "kind": "fitness", + "orders": [ + { "alt": ["HCPCS|94150", "HCPCS|94014"] }, + "HCPCS|71020", + { + "key": "LOINC|85216-0", + "after": ["HCPCS|94150", "HCPCS|71020"] + } + ] + }, + "OSHA|1910.1025": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "Quest Order|3058", + "Quest Order|22996", + "Quest Order|948", + "LabCorp Order|005009", + "LabCorp Order|003772" + ] + }, + "OSHA|1926.62": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "Quest Order|3058", + "Quest Order|22996", + "Quest Order|948", + "LabCorp Order|005009" + ] + }, + "OSHA|1910.1001": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|71020", "HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1053": { + "kind": "surveillance", + "periodicityMonths": 36, + "orders": ["HCPCS|71020", "HCPCS|94150", "LabCorp Order|182873"] + }, + "OSHA|1910.1028": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009", "LabCorp Order|007732"] + }, + "OSHA|1910.1027": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + "LabCorp Order|085340", + "LabCorp Order|072249", + "Quest Order|38994", + "Quest Order|4944" + ] + }, + "OSHA|1910.1026": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "OSHA|1910.1030": { + "kind": "surveillance", + "orders": [ + "CVX|45", + "CVX|189", + "Quest Order|499", + "Quest Order|26526" + ] + }, + "OSHA|1910.120": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": [ + "LabCorp Order|005009", + "LabCorp Order|003772", + "HCPCS|94150", + "HCPCS|92551", + "HCPCS|99173" + ] + }, + "OSHA|1910.1048": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1052": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "OSHA|1910.1018": { + "kind": "surveillance", + "periodicityMonths": 6, + "orders": [ + "LabCorp Order|007039", + "LabCorp Order|007245", + "HCPCS|71020" + ] + }, + "OSHA|1910.1043": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["HCPCS|94150", "HCPCS|94014"] + }, + "OSHA|1910.1045": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009", "HCPCS|71020"] + }, + "OSHA|1910.1051": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "OSHA|1910.1096": { + "kind": "surveillance", + "periodicityMonths": 12, + "orders": ["LabCorp Order|005009"] + }, + "FMCSA|391.41": { + "kind": "fitness", + "periodicityMonths": 24, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|003772"] + }, + "NFPA|1582": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": [ + "HCPCS|94150", + "HCPCS|92551", + "HCPCS|99173", + "LabCorp Order|005009", + "HCPCS|71020" + ] + }, + "FAA|67": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|003772"] + }, + "OPM|GS-1811": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": [ + "HCPCS|99173", + { "alt": ["HCPCS|92551", "HCPCS|0209T"] }, + "HCPCS|G0403", + "LabCorp Order|221010", + "LabCorp Order|790352", + { + "key": "LOINC|85216-0", + "after": [ + "HCPCS|99173", + "HCPCS|G0403", + "LabCorp Order|221010", + "LabCorp Order|790352" + ] + } + ] + }, + "USCG|46 CFR 10.302": { + "kind": "credential", + "periodicityMonths": 24, + "orders": ["HCPCS|99173", "HCPCS|92551", "LabCorp Order|790352"] + }, + "MSHA|30 CFR 90": { + "kind": "surveillance", + "periodicityMonths": 60, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "NIOSH|CWHSP": { + "kind": "surveillance", + "periodicityMonths": 60, + "orders": ["HCPCS|71020", "HCPCS|94150"] + }, + "NRC|10 CFR 26": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["LabCorp Order|790352", "LabCorp Order|790390"] + }, + "DOE|10 CFR 712": { + "kind": "fitness", + "periodicityMonths": 12, + "orders": ["LabCorp Order|790352", "HCPCS|G0403"] + }, + "USCIS|I-693": { + "kind": "credential", + "orders": [ + "Quest Order|19453", + "Quest Order|17389", + "Quest Order|11362", + "CVX|45" + ] + }, + "DOD|DoDI 6130.03": { + "kind": "fitness", + "orders": [ + "HCPCS|99173", + "HCPCS|92551", + "LabCorp Order|003772", + "LabCorp Order|790352" + ] + }, + "eCQM|CMS122": { + "kind": "quality", + "periodicityMonths": 12, + "ageMin": 18, + "ageMax": 75, + "orders": ["LabCorp Order|001453", "Quest Order|16320"] + }, + "eCQM|CMS124": { + "kind": "quality", + "periodicityMonths": 36, + "sex": "F", + "ageMin": 21, + "ageMax": 64, + "orders": ["LabCorp Order|009100"] + }, + "eCQM|CMS125": { + "kind": "quality", + "periodicityMonths": 24, + "sex": "F", + "ageMin": 40, + "ageMax": 74, + "orders": ["HCPCS|77057"] + }, + "eCQM|CMS130": { + "kind": "quality", + "periodicityMonths": 120, + "ageMin": 45, + "ageMax": 75, + "orders": [{ "alt": ["HCPCS|44388", "LabCorp Order|182949"] }] + }, + "eCQM|CMS117": { + "kind": "quality", + "ageMax": 2, + "orders": ["CVX|03", "CVX|120", "CVX|45"] + }, + "eCQM|CMS147": { + "kind": "quality", + "periodicityMonths": 12, + "orders": ["CVX|88"] + }, + "eCQM|CMS127": { + "kind": "quality", + "ageMin": 65, + "orders": ["CVX|133"] + }, + "eCQM|CMS153": { + "kind": "quality", + "periodicityMonths": 12, + "sex": "F", + "ageMin": 16, + "ageMax": 24, + "orders": ["Quest Order|11363"] + } + } +} 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/HealthSurveillanceSprint.md b/HealthSurveillanceSprint.md new file mode 100644 index 00000000..cce07cbf --- /dev/null +++ b/HealthSurveillanceSprint.md @@ -0,0 +1,80 @@ +# Health Surveillance Sprint + +Tracking the multi-phase build of health surveillance: programs metadata, +new regulated verticals, eCQMs, patient history + due engine, and the +HealthSurveillance component. Repos: mieweb/ui (branch `clinical-components`, +PR #299) and mieweb/codify (`packages/codify`, branch `main`). + +## Phase 1 β€” programs.json metadata model +- [x] programs.json supersedes order-sets.json (orders + kind + periodicityMonths + age/sex) +- [x] build-index.mjs copies programs.json to each locale output +- [x] Worker loads programs.json (falls back to order-sets.json), returns program metadata with drill results +- [x] CodeLookup `programsUrl` prop β€” developer/employer metadata override +- [x] Verify: MedicalSurveillance story drill unchanged + +## Phase 2 β€” new regulated verticals +- [x] occupational.tsv: OPM GS-1811, USCG CG-719K, MSHA/NIOSH CWHSP, NRC Part 26, DOE HRP, USCIS I-693 (+ DoDI 6130.03) +- [x] build-index DOMAINS: OPM, USCG, MSHA, NIOSH, NRC, DOE, USCIS, DOD codetypes +- [x] programs.json entries with verified order codes +- [x] aliases: cwhsp, hrp, i693, mariner (1811 matches via label token) +- [x] Rebuild shards + browser verify + +## Phase 3 β€” eCQMs: quality domain +- [x] Curated quality.tsv (CMS measures: 122, 124, 125, 130, 117, 147, 127, 153) +- [x] programs.json quality entries with age/sex/periodicity criteria +- [x] Engine: quality families keyed by measure id; component color + noun +- [x] QualityMeasures story +- [x] Rebuild shards + browser verify + +## Phase 4 β€” PatientHistory + due engine +- [x] history.ts: PatientHistory types (orders w/ status, observations, procedures, conditions, allergies, age, sex) +- [x] evaluate.ts: evaluateDue(history, programs) β†’ due/overdue/pending/satisfied/not-applicable +- [x] Vitest unit tests for evaluation logic (12 passing) + +## Phase 5 β€” HealthSurveillance component +- [x] Due list + Done list rendered from evaluateDue +- [x] Multi-select order picklist (checkboxes β†’ "Add N orders", onOrderMany) +- [x] dueForOrder helper for ordering prompts/alerts +- [x] Assessment interop: SurveillanceOrderPick carries programKey/programLabel for concernId linking + +## Phase 6 β€” chart demo story +- [x] Sample patient history (55 F factory worker; noise + lead + BBP programs; CMS measures) +- [x] Story panels: Due/Done list, Medications, Allergies, Conditions, Lab results (ChartDemo) +- [ ] Assessment variant with program-as-concern + due orders (follow-up: compose Assessment + HealthSurveillance in one story) + +## Phase 7 β€” docs, tests, CI +- [x] CodeLookup README: programs.json format, programsUrl override, health-surveillance umbrella +- [x] history/evaluate semantics documented inline + story autodocs +- [x] Unit tests: familyKey/familyTerm/normalize (engine), due engine β€” 21 passing +- [x] Lint + typecheck green, committed on clinical-components + +## Progress log +- 2026-07-04: Sprint file created; starting Phase 1. +- 2026-07-04: Phase 1 done β€” programs.json (kind/periodicity metadata) replaces + order-sets.json; worker fallback + programsUrl override; 'programs' worker + message for the due engine; verified lead drill resolves 5 orders. +- 2026-07-04: Phase 2 done β€” 8 new verticals (29 programs total); GS-1811 + drill resolves vision/audiometry/EKG/lipid/drug screen; hrp, i693, cwhsp, + mariner aliases verified in browser. +- 2026-07-04: Phase 3 done β€” quality domain (eCQM codetype, 8 CMS measures) + with age/sex/periodicity metadata; CMS130 drill resolves colonoscopy + FIT. +- 2026-07-04: Phase 4 done β€” PatientHistory types + pure due engine + (evaluateProgram/evaluateDue/dueForOrder) with enrollment semantics for + occupational vs quality; 12 unit tests passing. +- 2026-07-04: Phases 5+6 done β€” HealthSurveillance component (due/done lists, + multi-select order picklist) + DueList/ChartDemo stories; browser-verified: + overdue lead & A1c, "Add 4 orders" batch linked to the lead program. + Note: an old shared browser tab had environmental /codify/* request + blocking β€” fresh tab works; not a product issue. +- 2026-07-04: Phase 7 done β€” README section, engine + due-engine tests (21 + passing), lint/typecheck green. Follow-ups: Assessment+HealthSurveillance + composite story; dueForOrder badges inside the Assessment add-order flow; + allergy domain extract. +- 2026-07-04: Post-sprint: Assessment "Add concern" wording; concerns rank + first in auto (preferDomains tiers + viaFuzzy guard); ICD-10 boosted over + SNOMED (boostCodetypes); billableOnly leaf-ICD-10 filter + story. +- 2026-07-04: Structured program orders β€” { alt } one-of groups (colonoscopy + OR FIT, mutually exclusive checkboxes) and { key, after } dependencies + (RMO fitness-for-duty determination blocked until panel results complete; + supports multiple determinations per panel/SEG). Due-list picklist renders + dependency lanes with disabled "after …" entries; 26 tests green. diff --git a/OrdersGridPlan.md b/OrdersGridPlan.md new file mode 100644 index 00000000..79d0aeeb --- /dev/null +++ b/OrdersGridPlan.md @@ -0,0 +1,86 @@ +# Orders Grid Plan β€” EncounterOrdersGrid + ChartOrdersGrid + +NITRO-backed (DataVis) grid views over the order history, complementing the +HealthSurveillance due-list card. Rows are **encounter orders** β€” orders placed +during encounters β€” which get bundled into **requisitions** (documents routing +orders to a provider/referral). + +- **ChartOrdersGrid** β€” chart-wide: every pending and past order across all + encounters. Group/filter by reason (program), provider, requisition, status, + kind, and date. +- **EncounterOrdersGrid** β€” the current encounter's orders: due-list picks and + pending unprocessed items, with multi-select mass operations (order, bundle + into requisition, cancel). + +Both render `DataVisNitroGrid` inside a `DataVisNitroSource` fed by an +object-URL `{ typeInfo, data }` payload (NITRO's `local` source type is not +yet implemented). Grouping presets use `DataVisNitroContext` β†’ +`view.setGroup()`; users can also group/filter freely via `showControls`. + +## Phase 1 β€” Data model + flatten engine + +- [x] `history.ts`: optional `provider?`, `requisitionId?`, `encounterId?` on + `HistoryOrder` (requisition = routing document; encounter = visit) +- [x] `orderRows.ts`: `OrderRow` type + `buildChartOrderRows(history, programs, + opts)` β€” one row per history order **plus** one row per orderable + due-list order key; reason resolved by reverse program lookup; + per-order status `completed | pending | available | blocked` +- [x] `buildEncounterOrderRows(history, programs, { encounterId, … })` β€” + current-encounter subset + actionable due-list rows +- [x] Unit tests for both builders (alt groups, blocked deps, provider / + requisition / encounter passthrough) +- [x] Commit + +## Phase 2 β€” Shared grid infrastructure + +- [x] `useOrderRowsUrl` hook β€” rows β†’ `{ typeInfo, data }` object-URL with + dates typed `date` (revoked on change/unmount) +- [x] `ordersGridShared` internals: status-badge `formatCell`, group-preset + chips (context + `setGroup`/`clearGroup`), selection action bar +- [x] Commit + +## Phase 3 β€” ChartOrdersGrid + +- [x] `ChartOrdersGrid.tsx` β€” controlFields (Reason, Provider, Requisition, + Status, Kind, Date), filters, group presets; `onRequisition` / + `onCancel` for selected pending unprocessed rows +- [x] Export from `index.ts` +- [x] Story: enriched sample history (providers, requisition + encounter IDs); + `ChartOrders` story under Healthcare/HealthSurveillance +- [x] Commit + +## Phase 4 β€” EncounterOrdersGrid + +- [x] `EncounterOrdersGrid.tsx` β€” current-encounter rows; mass ops + **Order (N)** / **Create requisition (N)** / **Cancel (N)** via + `onOrderMany` / `onRequisition` / `onCancel` +- [x] ChartDemo story: toggle HealthSurveillance card ↔ EncounterOrdersGrid + sharing placed-orders state +- [x] Commit + +## Phase 5 β€” Verification & polish + +- [x] `vitest run src/components/HealthSurveillance` green (21 tests) +- [x] Type/lint clean on touched files +- [x] Storybook manual pass: group by each preset, filter by status, + multi-select action bar; screenshots +- [x] Final commit + +Verified in-browser on the EncounterOrders/ChartOrders stories: group-by +reason and provider presets, row multiselect (pending unprocessed row β†’ +Create requisition (1)/Cancel (1) enabled, Order (0) disabled), requisition +callback log. Known quirk: the ChartDemo story page exhibits a continuous +layout instability that defeats Playwright's actionability checks (affects +pre-existing elements too, e.g. the due-item expander) β€” needs a separate +investigation; the due-list ↔ grid toggle there should be exercised by hand. + +Note: both grids share `OrdersGrid.tsx` internals, so Phases 3–4 landed as +one commit. Grids are exported from the **`@mieweb/ui/datavis` entry** (not +the main entry) because `@mieweb/datavis` is an optional peer dependency; +the pure row builders stay in the main entry. + +## Out of scope + +- Assessment ↔ grid toggle inside the Assessment component (follow-up) +- Real requisition document generation (callbacks only) +- Upstream `datavis` `local` source implementation diff --git a/eslint.config.js b/eslint.config.js index 34e3210d..b0de9e92 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -90,6 +90,20 @@ export default [ // Encoding APIs atob: 'readonly', btoa: 'readonly', + TextDecoder: 'readonly', + TextEncoder: 'readonly', + // Workers / fetch / binary + Worker: 'readonly', + fetch: 'readonly', + Response: 'readonly', + DecompressionStream: 'readonly', + MessageEvent: 'readonly', + self: 'readonly', + performance: 'readonly', + DataView: 'readonly', + Uint32Array: 'readonly', + Float32Array: 'readonly', + Int32Array: 'readonly', // Observer APIs IntersectionObserver: 'readonly', IntersectionObserverEntry: 'readonly', diff --git a/src/components/AllergyList/AllergyList.stories.tsx b/src/components/AllergyList/AllergyList.stories.tsx new file mode 100644 index 00000000..da3ae159 --- /dev/null +++ b/src/components/AllergyList/AllergyList.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { AllergyList, type Allergy } from './AllergyList'; +import { AllergyManager } from './AllergyManager'; +import { CodeLookup } from '../CodeLookup'; + +const meta: Meta = { + title: 'Healthcare/AllergyList', + component: AllergyList, + parameters: { + layout: 'padded', + docs: { + description: { + component: ` +Allergy / intolerance list, following the same three-layer pattern as +**Healthcare/MedicationList**: + +| Component | Use when | +|---|---| +| \`AllergyManager\` | You want the whole workflow β€” editor, notes, NKA, inline add | +| \`AllergyList\` | You need full control and will supply your own dialogs | + +Entries group by category (Drug β†’ Food β†’ Environmental β†’ Other) with +severity badges. The empty state is deliberately **tri-state**: an empty +list reads "Allergy status not recorded" until *no known allergies* (NKA) +is explicitly confirmed β€” an empty allergy list must never silently imply +"no allergies". + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const sampleAllergies: Allergy[] = [ + { + id: '1', + allergen: 'penicillin', + type: 'drug', + reaction: 'hives', + severity: 'moderate', + onsetDate: '2019', + }, + { + id: '2', + allergen: 'sulfa drugs', + type: 'drug', + reaction: 'rash', + severity: 'mild', + }, + { + id: '5', + allergen: 'erythromycin', + type: 'drug', + kind: 'intolerance', + reaction: 'GI upset', + severity: 'mild', + }, + { + id: '3', + allergen: 'peanuts', + type: 'food', + reaction: 'anaphylaxis', + severity: 'severe', + note: 'Carries EpiPen', + }, + { + id: '4', + allergen: 'latex', + type: 'environmental', + reaction: 'contact dermatitis', + severity: 'mild', + inactive: true, + }, +]; + +/** + * The full experience with the editor and offline allergen coding. + */ +export const Interactive: StoryObj = { + render: () => ( + console.log('allergies changed', allergies)} + /> + ), + parameters: { + docs: { + description: { + story: ` +**Start here.** \`AllergyManager\` with everything wired: + +- Hover/focus a row for **Correct / Notes / Remove**; drag by the grip to + reorder within a category +- **Correct** and **Add allergy…** open the editor: **Allergy vs + Intolerance** mechanism radio (an intolerance mislabeled as an allergy + causes unnecessary avoidance of first-line drugs β€” intolerances get a + gray badge), type-driven allergen input (Drug β†’ coded RxNorm/FDB search; + Food/Environmental β†’ free text), reaction, severity radio, onset, note +- Severity renders as a colored badge (severe = red, moderate = amber); + inactive entries strike through +- Every change surfaces through \`onChange\` (browser console) + `, + }, + }, + }, +}; + +/** + * Patient intake from empty β€” tri-state NKA, non-leading inline search. + */ +export const EmptyIntake: StoryObj = { + render: function EmptyIntakeStory() { + return ; + }, + parameters: { + docs: { + description: { + story: ` +**Patient-facing intake from an empty list.** No suggested allergens +(suggestions would lead the patient) β€” just the inline search bar and the +explicit NKA affordance: + +- The empty state reads **"Allergy status not recorded."** β€” deliberately + *not* "no allergies", because an unasked question is not a negative + finding +- **Confirm: no known allergies** records the NKA finding explicitly + (βœ“ No known allergies) +- Typing in the search bar and picking a result adds a coded drug allergen + immediately (free text works for foods/other); adding any allergy + clears the NKA flag automatically +- Use **Add allergy…** for full detail (reaction, severity, onset) + `, + }, + }, + }, +}; + +function AllergyManagerWithNka() { + const [nka, setNka] = useState(false); + return ( + console.log('allergies changed', allergies)} + /> + ); +} + +/** Presentational layer, display only. */ +export const ReadOnly: Story = { + args: { + allergies: sampleAllergies, + readOnly: true, + }, + parameters: { + docs: { + description: { + story: ` +\`AllergyList\` with \`readOnly\` β€” no toolbars, no add affordances. For +chart summaries and printouts, or viewers without edit permission. + `, + }, + }, + }, +}; + +/** Confirmed no-known-allergies state. */ +export const NoKnownAllergies: Story = { + args: { + allergies: [], + noKnownAllergies: true, + readOnly: true, + }, + parameters: { + docs: { + description: { + story: ` +The confirmed-negative state: βœ“ **No known allergies (NKA)**. Distinct +from an empty list ("Allergy status not recorded") β€” see **EmptyIntake** +for why the distinction is load-bearing. + `, + }, + }, + }, +}; diff --git a/src/components/AllergyList/AllergyList.tsx b/src/components/AllergyList/AllergyList.tsx new file mode 100644 index 00000000..a4a5d9d7 --- /dev/null +++ b/src/components/AllergyList/AllergyList.tsx @@ -0,0 +1,479 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '../../utils/cn'; +import { Badge } from '../Badge/Badge'; +import { Button } from '../Button'; +import { Card, CardHeader, CardContent } from '../Card/Card'; +import { useLiveAnnouncement } from '../../hooks/useLiveAnnouncement'; +import { RowActionToolbar, RowIconButton } from '../RowActionToolbar'; +import { + useDragReorder, + dragIndicatorClasses, + type UseDragReorderReturn, +} from '../../hooks/useDragReorder'; +import { + PencilIcon, + StickyNoteIcon, + BanIcon, + PlusIcon, + AlertCircleIcon, + GripVerticalIcon, +} from '../Icons'; + +// ============================================================================= +// Types +// ============================================================================= + +/** Allergy category (FHIR AllergyIntolerance.category). */ +export type AllergyType = 'drug' | 'food' | 'environmental' | 'other'; + +/** + * Mechanism distinction (FHIR AllergyIntolerance.type): a true + * immune-mediated allergy vs a non-immune intolerance (e.g. GI upset). + * The distinction matters clinically β€” an intolerance mislabeled as an + * allergy causes unnecessary avoidance of first-line drugs. + */ +export type AllergyKind = 'allergy' | 'intolerance'; + +/** Reaction severity (FHIR reaction.severity). */ +export type AllergySeverity = 'mild' | 'moderate' | 'severe'; + +/** Coding-system reference for an allergen (RxNorm / FDB / SNOMED …). */ +export interface AllergyCode { + system: string; + code: string; + display?: string; +} + +/** + * A single allergy / intolerance. + * + * Loosely follows FHIR AllergyIntolerance: allergen (+ optional code), + * category, reaction, severity, onset, and clinical status. + */ +export interface Allergy { + /** Stable unique id */ + id: string; + /** Allergen display name, e.g. "penicillin" */ + allergen: string; + /** Category β€” groups the list */ + type?: AllergyType; + /** Allergy vs intolerance (FHIR AllergyIntolerance.type; default 'allergy') */ + kind?: AllergyKind; + /** Reaction description, e.g. "hives" */ + reaction?: string; + /** Reaction severity β€” drives the badge color */ + severity?: AllergySeverity; + /** Onset date (display string) */ + onsetDate?: string; + /** Free-text note */ + note?: string; + /** Inactive/resolved entries render struck through */ + inactive?: boolean; + /** Code reference (RxNorm/FDB for drugs, SNOMED otherwise) */ + code?: AllergyCode; +} + +/** Row actions revealed on hover. */ +export type AllergyAction = 'correct' | 'note' | 'remove'; + +export interface AllergyListProps extends Omit< + React.HTMLAttributes, + 'className' | 'title' +> { + /** Allergies to display (controlled) */ + allergies: Allergy[]; + /** + * No-known-allergies flag (NKA/NKDA). An empty list is ambiguous β€” + * "not asked" vs "asked, none" β€” so this is explicit tri-state: + * undefined = not recorded, true = no known allergies. + * Ignored when `allergies` is non-empty. + */ + noKnownAllergies?: boolean; + /** Called when the no-known-allergies affordance is toggled */ + onNoKnownAllergiesChange?: (value: boolean) => void; + /** Header title (pass null to hide) */ + title?: string | null; + /** Row actions to show (default: all) */ + actions?: AllergyAction[]; + /** Called when a row action is clicked */ + onAction?: (allergy: Allergy, action: AllergyAction) => void; + /** + * Called with the full new id order after a drag/keyboard reorder. + * Omit to disable reordering. Drops are restricted to the same + * category group. + */ + onReorder?: (allergyIds: string[]) => void; + /** Custom add UI (e.g. an inline CodeLookup search) */ + addSearch?: React.ReactNode; + /** Called when the "Add allergy" button is clicked (omit to hide) */ + onAdd?: () => void; + /** Hide all action affordances (display only) */ + readOnly?: boolean; + /** Additional CSS classes */ + className?: string; + /** Test ID for testing */ + 'data-testid'?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const TYPE_ORDER: AllergyType[] = ['drug', 'food', 'environmental', 'other']; + +export const ALLERGY_TYPE_LABELS: Record = { + drug: 'Drug', + food: 'Food', + environmental: 'Environmental', + other: 'Other', +}; + +const SEVERITY_BADGE: Record< + AllergySeverity, + 'danger' | 'warning' | 'secondary' +> = { + severe: 'danger', + moderate: 'warning', + mild: 'secondary', +}; + +const DEFAULT_ACTIONS: AllergyAction[] = ['correct', 'note', 'remove']; + +const ACTION_META: Record< + AllergyAction, + { label: string; icon: React.ComponentType<{ size?: number | string }> } +> = { + correct: { label: 'Correct', icon: PencilIcon }, + note: { label: 'Notes', icon: StickyNoteIcon }, + remove: { label: 'Remove', icon: BanIcon }, +}; + +// ============================================================================= +// Sub-components +// ============================================================================= + +function AllergyRow({ + allergy, + actions, + readOnly, + drag, + onMove, + onAction, +}: { + allergy: Allergy; + actions: AllergyAction[]; + readOnly: boolean; + drag: UseDragReorderReturn; + /** Keyboard equivalent of drag reordering (Alt+↑/↓) */ + onMove?: (allergy: Allergy, dir: -1 | 1) => void; + onAction?: (allergy: Allergy, action: AllergyAction) => void; +}) { + const handleRowKeyDown = (e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget) return; + if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + e.preventDefault(); + onMove?.(allergy, e.key === 'ArrowUp' ? -1 : 1); + } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const list = e.currentTarget.closest('ul'); + if (!list) return; + const rows = Array.from( + list.querySelectorAll('li[data-allergy-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 ( + // Rows are focus stops when reordering is enabled so drag & drop has a + // keyboard equivalent (Alt+↑/↓) β€” 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 ? ( + + ) : ( + β€’ + )} + + {allergy.allergen} + + {allergy.code && ( + + {allergy.code.system} {allergy.code.code} + + )} + {allergy.severity && ( + + {allergy.severity} + + )} + {allergy.kind === 'intolerance' && ( + + intolerance + + )} + {allergy.reaction && ( + + β€” {allergy.reaction} + + )} + {allergy.onsetDate && ( + + since {allergy.onsetDate} + + )} + {allergy.inactive && ( + + inactive + + )} + {allergy.note && ( + + + {allergy.note} + + )} + + {!readOnly && actions.length > 0 && ( + + {actions.map((action) => ( + onAction?.(allergy, action)} + /> + ))} + + )} +
  • + ); +} + +// ============================================================================= +// AllergyList +// ============================================================================= + +/** + * Allergy / intolerance list β€” presentational layer. + * + * Controlled: allergies come in via props; every interaction is reported + * through callbacks. Entries group by category (Drug β†’ Food β†’ + * Environmental β†’ Other); severity renders as a colored badge; hover (or + * keyboard-focus) a row for Correct / Notes / Remove. + * + * The empty state is explicit tri-state via `noKnownAllergies` (NKA): + * an empty list shows "Allergy status not recorded" until the No Known + * Allergies affordance is confirmed β€” an empty list must never silently + * read as "no allergies". + * + * Pair with `AllergyManager` for the batteries-included experience. + */ +export const AllergyList = React.forwardRef( + ( + { + allergies, + noKnownAllergies, + onNoKnownAllergiesChange, + title = 'Allergies', + actions = DEFAULT_ACTIONS, + onAction, + onReorder, + addSearch, + onAdd, + readOnly = false, + className, + 'data-testid': dataTestId, + ...props + }, + ref + ) => { + const [announcement, setAnnouncement] = useLiveAnnouncement(); + + const groups = TYPE_ORDER.map((type) => ({ + type, + label: ALLERGY_TYPE_LABELS[type], + items: allergies.filter((a) => (a.type ?? 'other') === type), + })).filter((g) => g.items.length > 0); + + const empty = allergies.length === 0; + + // Drag & drop reordering, restricted to the same category group. + const typeOf = React.useCallback( + (id: string) => allergies.find((a) => a.id === id)?.type ?? 'other', + [allergies] + ); + const drag = useDragReorder({ + ids: allergies.map((a) => a.id), + onReorder: + readOnly || !onReorder + ? undefined + : (ids) => { + setAnnouncement('Allergy list reordered'); + onReorder(ids); + }, + canDropOn: (dragged, target) => typeOf(dragged) === typeOf(target), + }); + + /** Keyboard equivalent of dragging a row: swap within the category. */ + const moveAllergy = React.useCallback( + (allergy: Allergy, dir: -1 | 1) => { + if (readOnly || !onReorder) return; + const ids = allergies.map((a) => a.id); + const from = ids.indexOf(allergy.id); + let to = from + dir; + while ( + to >= 0 && + to < allergies.length && + (allergies[to].type ?? 'other') !== (allergy.type ?? 'other') + ) { + to += dir; + } + if (to < 0 || to >= allergies.length) return; + [ids[from], ids[to]] = [ids[to], ids[from]]; + setAnnouncement( + `${allergy.allergen} moved ${dir === -1 ? 'up' : 'down'}` + ); + onReorder(ids); + }, + [allergies, onReorder, readOnly, setAnnouncement] + ); + + return ( + + {title !== null && ( + +

    + + {title} +

    +
    + )} + + + {empty ? ( +
    + {noKnownAllergies ? ( +

    + + βœ“ + + No known allergies (NKA) +

    + ) : ( +

    + Allergy status not recorded. +

    + )} + {!readOnly && onNoKnownAllergiesChange && ( + + )} +
    + ) : ( + groups.map(({ type, label, items }) => ( +
    +

    + {label} +

    +
      + {items.map((allergy) => ( + + ))} +
    +
    + )) + )} + + {!readOnly && (addSearch || onAdd) && ( +
    + + Add allergy + + {addSearch} + {onAdd && ( + + )} +
    + )} +
    + +
    + {announcement} +
    +
    + ); + } +); + +AllergyList.displayName = 'AllergyList'; + +export default AllergyList; diff --git a/src/components/AllergyList/AllergyManager.tsx b/src/components/AllergyList/AllergyManager.tsx new file mode 100644 index 00000000..1e579aa8 --- /dev/null +++ b/src/components/AllergyList/AllergyManager.tsx @@ -0,0 +1,448 @@ +'use client'; + +/** + * AllergyManager β€” batteries-included allergy list. + * + * Wraps the presentational `AllergyList` with the interaction layer: the + * allergy editor (Correct / Add with CodeLookup allergen coding), the note + * dialog, removal, and the no-known-allergies (NKA) flow. Uncontrolled via + * `defaultAllergies` / controlled via `allergies` + `onChange` β€” same + * pattern as `MedicationReconciliation`. + */ + +import * as React from 'react'; +import { + AllergyList, + type Allergy, + type AllergyAction, + type AllergyKind, + type AllergySeverity, + type AllergyType, +} from './AllergyList'; +import { + Modal, + ModalHeader, + ModalTitle, + ModalClose, + ModalBody, + ModalFooter, +} from '../Modal'; +import { Button } from '../Button'; +import { Input } from '../Input'; +import { Textarea } from '../Textarea'; +import { Label } from '../Label'; +import { Select } from '../Select'; +import { RadioGroup, Radio } from '../Radio'; +import { DateInput } from '../DateInput'; +import type { CodeLookupConfig } from '../MedicationList'; +import { useCodeLookupConfig } from '../CodeLookup/context'; + +// ============================================================================= +// Types +// ============================================================================= + +export interface AllergyManagerProps { + /** Controlled allergy list (use with `onChange`) */ + allergies?: Allergy[]; + /** Initial allergy list (uncontrolled) */ + defaultAllergies?: Allergy[]; + /** Called with the next list after every mutation */ + onChange?: (allergies: Allergy[]) => void; + /** No-known-allergies flag (controlled; see AllergyList) */ + noKnownAllergies?: boolean; + /** Called when NKA is toggled */ + onNoKnownAllergiesChange?: (value: boolean) => void; + /** Header title (pass null to hide) */ + title?: string | null; + /** + * CodeLookup wiring for allergen search (drug shard). Enables both the + * inline add bar and coded allergens in the editor. Defaults to the ambient + * `CodeLookupProvider`; pass `false` to force plain text. + */ + codeLookup?: CodeLookupConfig | false; + /** Render an inline allergen search bar in the add section (requires codeLookup) */ + inlineAddSearch?: boolean; + /** Hide all action affordances (display only) */ + readOnly?: boolean; + /** Additional CSS classes */ + className?: string; + /** Test ID for testing */ + 'data-testid'?: string; +} + +function newId(): string { + return `alg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +// ============================================================================= +// Editor dialog +// ============================================================================= + +const TYPE_OPTIONS = [ + { value: 'drug', label: 'Drug' }, + { value: 'food', label: 'Food' }, + { value: 'environmental', label: 'Environmental' }, + { value: 'other', label: 'Other' }, +]; + +function AllergyEditor({ + allergy, + codeLookup, + onClose, + onSave, +}: { + allergy?: Allergy; + codeLookup?: CodeLookupConfig; + onClose: () => void; + onSave: (allergy: Allergy) => void; +}) { + const [draft, setDraft] = React.useState( + () => allergy ?? { id: newId(), allergen: '', type: 'drug' } + ); + + const patch = (p: Partial) => + setDraft((prev) => ({ ...prev, ...p })); + + // Focus the allergen search / input when the dialog opens. + const bodyRef = React.useRef(null); + React.useEffect(() => { + bodyRef.current?.querySelector('input')?.focus(); + }, []); + + const canSave = draft.allergen.trim().length > 0; + + // CodeLookup's shards cover drugs (RxNorm/FDB β€” the 'med' domain); food + // and environmental allergens have no shard, so the search only renders + // for drug-type allergies and other types use a plain text input. + const isDrug = (draft.type ?? 'drug') === 'drug'; + + return ( + !o && onClose()} size="md"> + + {allergy ? 'Correct Allergy' : 'Add Allergy'} + + + +
    + patch({ kind: v as AllergyKind })} + orientation="horizontal" + size="sm" + > + + + + + patch({ allergen: e.target.value })} + /> +
    + )} + +
    + + patch({ reaction: e.target.value })} + /> +
    + + + patch({ severity: (v || undefined) as AllergySeverity }) + } + orientation="horizontal" + size="sm" + > + + + + + +
    + + patch({ onsetDate: v })} + /> +
    + +
    + +