feat(notes): structured ROS + PE + ICD-10 diagnosis pickers (WellVisit, SickVisit)

Ports the vanilla ROS / Physical Exam / ICD-10 diagnosis cards from
public/js/shadess.js (@be14578) — renderRosRows, wireRosContainer,
renderDxComponent — which the earlier React port had collapsed into
plain-text textareas.

Clinical data → shared/clinical/ros-pe-dx.ts:
  • ROS_SYSTEMS (15 systems, each with label + detail hint)
  • PE_SYSTEMS (17 systems)
  • COMMON_DX (28 pediatric quick-pick ICD-10 codes)
  • formatRosForAI / formatDxForAI — byte-identical to vanilla so
    the server-side prompt context is unchanged.
  Tests: ros-pe-dx.test.ts asserts the table counts, ordering, and
  format strings — catches the class of bug where an LLM silently
  drops an entry or re-orders the clinical reference during a port.

New React components:
  client/src/components/RosPeTable.tsx — tri-state row (WNL/Abnormal/
    Not reviewed) with auto-revealed note field on Abnormal.
    rosAllWnl / rosClear helpers mirror the "All WNL" / "Clear"
    buttons from vanilla. Accepts a btnLabels prop so the same
    component renders ROS ("WNL"/"Not reviewed") and PE ("Normal"/
    "Not examined").

  client/src/components/DxPicker.tsx — ICD-10 live search via NLM
    Clinical Tables API (free, no auth, CORS-OK) with 280ms debounce
    + AbortController; chip-style selected tags with × remove;
    28-entry common-diagnosis quick-pick grid. Enter key adds the
    top result.

Wired into WellVisit / VisitNote.tsx and SickVisit.tsx:
  • Submit now sends ros / physicalExam / diagnoses as formatted
    strings to /api/well-visit/note and /api/sick-visit/note.
  • State persists through the Save/Load encounter flow via
    partialData → the rosData/peData/diagnoses round-trip.
This commit is contained in:
Daniel 2026-04-24 05:16:51 +02:00
parent 61749a7881
commit 10dbabf83b
6 changed files with 606 additions and 4 deletions

View file

@ -0,0 +1,143 @@
// ============================================================
// DxPicker — ICD-10 diagnosis picker. Live search via NLM Clinical
// Tables API (free, no auth, CORS-enabled) + a grid of common
// pediatric diagnoses for one-click add. Mirrors the vanilla
// renderDxComponent / searchIcd10 in public/js/shadess.js (@be14578).
//
// Selected diagnoses render as removable chips. Consumers pass the
// current array + a setter; the component owns search state only.
// ============================================================
import { useEffect, useRef, useState } from 'react';
import { COMMON_DX, type DxEntry } from '@shared/clinical/ros-pe-dx';
interface Props {
value: DxEntry[];
onChange: (next: DxEntry[]) => void;
testIdPrefix?: string;
}
const sm = 'rounded-md border border-input bg-background px-2 py-1 text-sm';
async function searchIcd10(q: string, signal: AbortSignal): Promise<DxEntry[]> {
const url = 'https://clinicaltables.nlm.nih.gov/api/icd10cm/v3/search?sf=code,name&terms='
+ encodeURIComponent(q) + '&maxList=12';
const r = await fetch(url, { signal });
const data = await r.json();
// NLM returns [count, [codes], null, [[code, name], …]]
const rows = (data[3] || []) as Array<[string, string]>;
return rows.map(([code, name]) => ({ code, name }));
}
export default function DxPicker({ value, onChange, testIdPrefix = 'dx' }: Props) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<DxEntry[]>([]);
const [open, setOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (!query.trim() || query.trim().length < 2) {
setResults([]); setOpen(false);
return;
}
if (timerRef.current) window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
if (abortRef.current) abortRef.current.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
searchIcd10(query.trim(), ctrl.signal)
.then((rows) => { setResults(rows); setOpen(rows.length > 0); })
.catch(() => { /* fetch aborted or failed — leave previous state */ });
}, 280);
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
}, [query]);
function add(dx: DxEntry) {
const already = value.some((d) => d.code === dx.code && d.name === dx.name);
if (already) return;
onChange([...value, dx]);
}
function remove(i: number) {
onChange(value.filter((_, idx) => idx !== i));
}
return (
<div className="space-y-2" data-testid={testIdPrefix + '-picker'}>
<div className="relative">
<input
type="text"
placeholder='Search ICD-10 (e.g. "otitis", "J06")…'
autoComplete="off"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => results.length && setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
onKeyDown={(e) => {
if (e.key === 'Escape') setOpen(false);
if (e.key === 'Enter' && results[0]) {
e.preventDefault();
add(results[0]);
setQuery('');
setOpen(false);
}
}}
className={sm + ' w-full'}
data-testid={testIdPrefix + '-search'}
/>
{open && results.length > 0 && (
<div className="absolute z-30 left-0 right-0 mt-1 max-h-60 overflow-auto rounded-md border border-border bg-card shadow-lg">
{results.map((r) => (
<button
key={r.code}
type="button"
onMouseDown={(e) => {
e.preventDefault();
add(r);
setQuery('');
setOpen(false);
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted border-b border-border last:border-0"
data-testid={testIdPrefix + '-result-' + r.code}
>
<span className="inline-block w-20 text-xs font-mono text-muted-foreground">{r.code}</span>
<span>{r.name}</span>
</button>
))}
</div>
)}
</div>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5" data-testid={testIdPrefix + '-tags'}>
{value.map((d, i) => (
<span key={i} className="inline-flex items-center gap-1 rounded-full bg-primary/10 border border-primary/30 text-primary px-2 py-0.5 text-xs">
{d.code && <strong className="font-semibold">{d.code}</strong>}
<span>{d.name}</span>
<button type="button" onClick={() => remove(i)} className="text-xs text-muted-foreground hover:text-destructive" title="Remove">×</button>
</span>
))}
</div>
)}
<div>
<div className="text-[11px] text-muted-foreground mb-1">Common pediatric diagnoses:</div>
<div className="flex flex-wrap gap-1">
{COMMON_DX.map((dx) => (
<button
key={dx.code}
type="button"
onClick={() => add(dx)}
className="text-[11px] rounded border border-border bg-background px-2 py-0.5 hover:bg-muted"
title={dx.name}
data-testid={testIdPrefix + '-chip-' + dx.code}
>
<span className="font-mono text-muted-foreground mr-1">{dx.code}</span>
{dx.name}
</button>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,97 @@
// ============================================================
// RosPeTable — structured ROS or PE input. Per-system row with
// WNL / Abnormal / Not reviewed toggle + a note field that reveals
// when "Abnormal" is selected. Mirrors window.renderRosRows /
// wireRosContainer from public/js/shadess.js (@be14578).
//
// Consumers (WellVisit Note, Sick Visit Note) pass systems list +
// data object + label set so the same component renders both
// review-of-systems and physical-exam tables.
// ============================================================
import type { RosData, RosStatus, SystemEntry } from '@shared/clinical/ros-pe-dx';
interface BtnLabels { wnl: string; abnormal: string; notrev: string }
interface Props {
systems: ReadonlyArray<SystemEntry>;
data: RosData;
onChange: (next: RosData) => void;
btnLabels?: BtnLabels;
testIdPrefix?: string;
}
export function rosAllWnl(systems: ReadonlyArray<SystemEntry>, data: RosData): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = { status: 'wnl', note: next[s.key]?.note };
return next;
}
export function rosClear(data: RosData, systems: ReadonlyArray<SystemEntry>): RosData {
const next: RosData = { ...data };
for (const s of systems) next[s.key] = {};
return next;
}
const DEFAULT_LABELS: BtnLabels = { wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' };
function statusClass(active: boolean, kind: RosStatus) {
if (!active) return 'bg-background border-border hover:bg-muted';
if (kind === 'wnl') return 'bg-green-100 text-green-800 border-green-300';
if (kind === 'abnormal') return 'bg-red-100 text-red-800 border-red-300';
return 'bg-muted text-muted-foreground border-border';
}
export default function RosPeTable({
systems, data, onChange, btnLabels, testIdPrefix = 'ros',
}: Props) {
const labels = btnLabels || DEFAULT_LABELS;
function setStatus(key: string, status: RosStatus) {
const cur = data[key]?.status || '';
const nextStatus: RosStatus = cur === status ? '' : status;
onChange({ ...data, [key]: { status: nextStatus, note: data[key]?.note || '' } });
}
function setNote(key: string, note: string) {
onChange({ ...data, [key]: { status: data[key]?.status || '', note } });
}
return (
<div className="divide-y divide-border" data-testid={testIdPrefix + '-table'}>
{systems.map((sys) => {
const cell = data[sys.key] || {};
const active = cell.status || '';
return (
<div key={sys.key} className="flex flex-wrap items-center gap-2 px-2 py-1.5" data-testid={testIdPrefix + '-row-' + sys.key}>
<div className="flex-1 min-w-[180px]" title={sys.detail}>
<span className="text-sm font-medium">{sys.label}</span>
<span className="ml-1 text-[10px] text-muted-foreground">({sys.detail})</span>
</div>
<div className="flex gap-1 shrink-0">
{(['wnl', 'abnormal', 'notrev'] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setStatus(sys.key, kind)}
className={'text-[10px] uppercase tracking-wider px-2 py-1 rounded border ' + statusClass(active === kind, kind)}
data-testid={testIdPrefix + '-btn-' + sys.key + '-' + kind}
>
{labels[kind]}
</button>
))}
</div>
{active === 'abnormal' && (
<input
type="text"
value={cell.note || ''}
onChange={(e) => setNote(sys.key, e.target.value)}
placeholder="Describe finding…"
className="flex-1 min-w-[200px] rounded-md border border-input bg-background px-2 py-1 text-xs"
data-testid={testIdPrefix + '-note-' + sys.key}
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -10,6 +10,16 @@ import { SickVisitRequestSchema, type SickVisitRequest } from '@/shared/schemas'
import Recorder from '@/components/Recorder';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable';
import DxPicker from '@/components/DxPicker';
import {
ROS_SYSTEMS,
PE_SYSTEMS,
formatRosForAI,
formatDxForAI,
type RosData,
type DxEntry,
} from '@shared/clinical/ros-pe-dx';
const TYPE = 'sickvisit' as const;
@ -20,6 +30,10 @@ export default function SickVisit() {
const [chiefComplaint, setChiefComplaint] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [rosData, setRosData] = useState<RosData>({});
const [peData, setPeData] = useState<RosData>({});
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
const [dxFreetext, setDxFreetext] = useState('');
const [result, setResult] = useState<string | null>(null);
const [validationError, setValidationError] = useState<string | null>(null);
const [recError, setRecError] = useState<string | null>(null);
@ -32,7 +46,16 @@ export default function SickVisit() {
function submit(e: React.FormEvent) {
e.preventDefault();
setValidationError(null);
const body: SickVisitRequest = { patientAge, patientGender, chiefComplaint, transcript: (interim || transcript).trim() };
const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'Review of Systems');
const peText = formatRosForAI(PE_SYSTEMS, peData, 'Physical Examination');
const dxText = formatDxForAI(diagnoses, dxFreetext);
const body: SickVisitRequest = {
patientAge, patientGender, chiefComplaint,
transcript: (interim || transcript).trim(),
ros: rosText || undefined,
physicalExam: peText || undefined,
diagnoses: dxText || undefined,
};
const parsed = SickVisitRequestSchema.safeParse(body);
if (!parsed.success) {
setValidationError(parsed.error.issues.map((i: { message: string }) => i.message).join(', '));
@ -58,7 +81,7 @@ export default function SickVisit() {
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, chiefComplaint }}
partialData={{ age: patientAge, gender: patientGender, chiefComplaint, rosData, peData, diagnoses, dxFreetext }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
@ -68,12 +91,17 @@ export default function SickVisit() {
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.chiefComplaint) setChiefComplaint(pd.chiefComplaint);
if (pd?.rosData) setRosData(pd.rosData);
if (pd?.peData) setPeData(pd.peData);
if (pd?.diagnoses) setDiagnoses(pd.diagnoses);
if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null); setValidationError(null);
setPatientAge(''); setPatientGender(''); setChiefComplaint('');
setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext('');
}}
/>
@ -119,6 +147,55 @@ export default function SickVisit() {
/>
</label>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
<div className="flex gap-1">
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All WNL</button>
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={ROS_SYSTEMS}
data={rosData}
onChange={setRosData}
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
testIdPrefix="sv-ros"
/>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
<div className="flex gap-1">
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All Normal</button>
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={PE_SYSTEMS}
data={peData}
onChange={setPeData}
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
testIdPrefix="sv-pe"
/>
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="sv-dx" />
<label className="block">
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
<input
type="text"
value={dxFreetext}
onChange={(e) => setDxFreetext(e.target.value)}
className={input + ' text-sm'}
placeholder="e.g. Follow up in 48 hours if not improving"
/>
</label>
</div>
{validationError && <div className="text-sm text-destructive">{validationError}</div>}
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}

View file

@ -12,6 +12,16 @@ import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import EditableResult from '@/components/EditableResult';
import Recorder from '@/components/Recorder';
import RosPeTable, { rosAllWnl, rosClear } from '@/components/RosPeTable';
import DxPicker from '@/components/DxPicker';
import {
ROS_SYSTEMS,
PE_SYSTEMS,
formatRosForAI,
formatDxForAI,
type RosData,
type DxEntry,
} from '@shared/clinical/ros-pe-dx';
const TYPE = 'wellvisit' as const;
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
@ -32,6 +42,10 @@ export default function VisitNote() {
const [screenings, setScreenings] = useState('');
const [vaccines, setVaccines] = useState('');
const [byvisit, setByvisit] = useState('');
const [rosData, setRosData] = useState<RosData>({});
const [peData, setPeData] = useState<RosData>({});
const [diagnoses, setDiagnoses] = useState<DxEntry[]>([]);
const [dxFreetext, setDxFreetext] = useState('');
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
const [result, setResult] = useState<string | null>(null);
@ -57,6 +71,9 @@ export default function VisitNote() {
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
const rosText = formatRosForAI(ROS_SYSTEMS, rosData, 'ROS');
const peText = formatRosForAI(PE_SYSTEMS, peData, 'PHYSICAL EXAM');
const dxText = formatDxForAI(diagnoses, dxFreetext);
generate.mutate({
patientAge, patientGender, visitAge,
vitals, measurements, parentConcerns,
@ -65,6 +82,9 @@ export default function VisitNote() {
// The byvisit summary is appended to screenings so the AI sees it.
screenings: [screenings, byvisit].filter(Boolean).join('\n\n'),
vaccines,
ros: rosText || undefined,
physicalExam: peText || undefined,
diagnoses: dxText || undefined,
// Milestones get folded into transcript context as a developmental block.
physicianMemories: milestones ? '[DEVELOPMENTAL ASSESSMENT]\n' + milestones : undefined,
noteStyle,
@ -79,7 +99,7 @@ export default function VisitNote() {
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, noteStyle }}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, shadess, milestones, byvisit, screenings, vaccines, rosData, peData, diagnoses, dxFreetext, noteStyle }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
@ -97,6 +117,10 @@ export default function VisitNote() {
if (pd?.byvisit) setByvisit(pd.byvisit);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.rosData) setRosData(pd.rosData);
if (pd?.peData) setPeData(pd.peData);
if (pd?.diagnoses) setDiagnoses(pd.diagnoses);
if (pd?.dxFreetext) setDxFreetext(pd.dxFreetext);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
@ -106,7 +130,9 @@ export default function VisitNote() {
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setShadess(''); setMilestones(''); setByvisit('');
setScreenings(''); setVaccines(''); setNoteStyle('full');
setScreenings(''); setVaccines('');
setRosData({}); setPeData({}); setDiagnoses([]); setDxFreetext('');
setNoteStyle('full');
}}
/>
@ -201,6 +227,55 @@ export default function VisitNote() {
</label>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Review of Systems</span>
<div className="flex gap-1">
<button type="button" onClick={() => setRosData(rosAllWnl(ROS_SYSTEMS, rosData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All WNL</button>
<button type="button" onClick={() => setRosData(rosClear(rosData, ROS_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={ROS_SYSTEMS}
data={rosData}
onChange={setRosData}
btnLabels={{ wnl: 'WNL', abnormal: 'Abnormal', notrev: 'Not reviewed' }}
testIdPrefix="wv-ros"
/>
</div>
<div className="rounded-lg border border-border bg-card">
<div className="px-3 py-2 flex items-center justify-between bg-muted/40 border-b border-border">
<span className="text-xs font-semibold uppercase tracking-wider">Physical Examination</span>
<div className="flex gap-1">
<button type="button" onClick={() => setPeData(rosAllWnl(PE_SYSTEMS, peData))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted"> All Normal</button>
<button type="button" onClick={() => setPeData(rosClear(peData, PE_SYSTEMS))} className="text-xs rounded border border-border px-2 py-1 hover:bg-muted">Clear</button>
</div>
</div>
<RosPeTable
systems={PE_SYSTEMS}
data={peData}
onChange={setPeData}
btnLabels={{ wnl: 'Normal', abnormal: 'Abnormal', notrev: 'Not examined' }}
testIdPrefix="wv-pe"
/>
</div>
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider">Diagnoses (ICD-10)</span>
<DxPicker value={diagnoses} onChange={setDiagnoses} testIdPrefix="wv-dx" />
<label className="block">
<span className="text-[11px] text-muted-foreground">Additional free-text diagnosis / note (optional)</span>
<input
type="text"
value={dxFreetext}
onChange={(e) => setDxFreetext(e.target.value)}
className={input + ' text-sm'}
placeholder="e.g. Rule out iron-deficiency anaemia pending labs"
/>
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Note style</span>
<select className={input} value={noteStyle} onChange={(e) => setNoteStyle(e.target.value as 'full' | 'short')}>

View file

@ -0,0 +1,80 @@
// Tests for shared/clinical/ros-pe-dx.ts. The clinical tables here
// are load-bearing — a silent reorder or deletion changes AI output.
// These tests catch that class of regression.
import { describe, expect, it } from 'vitest';
import {
ROS_SYSTEMS,
PE_SYSTEMS,
COMMON_DX,
formatRosForAI,
formatDxForAI,
} from './ros-pe-dx';
describe('reference tables', () => {
it('ROS has the 15 systems vanilla shipped, in order', () => {
expect(ROS_SYSTEMS.length).toBe(15);
expect(ROS_SYSTEMS[0].key).toBe('constitutional');
expect(ROS_SYSTEMS[ROS_SYSTEMS.length - 1].key).toBe('developmental');
});
it('PE has the 17 systems vanilla shipped, in order', () => {
expect(PE_SYSTEMS.length).toBe(17);
expect(PE_SYSTEMS[0].key).toBe('general');
expect(PE_SYSTEMS[PE_SYSTEMS.length - 1].key).toBe('pePsych');
});
it('COMMON_DX has the 28 pediatric diagnoses vanilla shipped', () => {
expect(COMMON_DX.length).toBe(28);
// Z00.129 is the default pediatric well-visit code — must be first.
expect(COMMON_DX[0]).toEqual({ code: 'Z00.129', name: 'Routine child health exam, no abnormal findings' });
});
it('every ROS/PE entry has a non-empty label + detail string', () => {
for (const s of [...ROS_SYSTEMS, ...PE_SYSTEMS]) {
expect(s.key).toBeTruthy();
expect(s.label).toBeTruthy();
expect(s.detail).toBeTruthy();
}
});
});
describe('formatRosForAI', () => {
const TWO = [ROS_SYSTEMS[0], ROS_SYSTEMS[1]];
it('returns empty string when nothing is reviewed', () => {
expect(formatRosForAI(TWO, {}, 'ROS')).toBe('');
});
it('emits NORMAL with the domain hint for WNL systems', () => {
const out = formatRosForAI(TWO, { constitutional: { status: 'wnl' } }, 'ROS');
expect(out).toContain('Constitutional: NORMAL [domain: fever, fatigue, weight loss/gain, appetite]');
});
it('emits ABNORMAL with the user note inline', () => {
const out = formatRosForAI(TWO, { eyes: { status: 'abnormal', note: 'conjunctival injection' } }, 'ROS');
expect(out).toContain('Eyes: ABNORMAL — conjunctival injection');
});
it('calls out systems marked Not Reviewed explicitly', () => {
const out = formatRosForAI(TWO, { eyes: { status: 'notrev' } }, 'ROS');
expect(out).toContain('Eyes: Not reviewed');
});
it('omits systems with no status set', () => {
const out = formatRosForAI(TWO, { constitutional: { status: 'wnl' } }, 'ROS');
expect(out).not.toContain('Eyes');
});
});
describe('formatDxForAI', () => {
it('returns empty when no dx AND no free text', () => {
expect(formatDxForAI([], '')).toBe('');
});
it('prefixes codes when present', () => {
const out = formatDxForAI([{ code: 'J06.9', name: 'URI' }], '');
expect(out).toContain('- J06.9 URI');
});
it('skips code prefix for free-text-only diagnoses', () => {
const out = formatDxForAI([{ code: '', name: 'Parental anxiety' }], '');
expect(out).toContain('- Parental anxiety');
expect(out).not.toContain(' Parental');
});
it('appends trimmed free-text as its own bullet', () => {
const out = formatDxForAI([{ code: 'R50.9', name: 'Fever' }], ' Rule out UTI ');
expect(out).toMatch(/Fever[\s\S]*Rule out UTI/);
expect(out).not.toContain(' Rule out UTI');
});
});

View file

@ -0,0 +1,130 @@
// ============================================================
// ROS / PE systems + common ICD-10 diagnoses + AI formatters.
// Ported verbatim from public/js/shadess.js (@be14578):
// ROS_SYSTEMS — 15 systems
// PE_SYSTEMS — 17 systems
// COMMON_DX — 28 pediatric diagnoses quick-pick
// formatRosForAI / formatDxForAI — string format the AI receives.
// These tables are clinical reference data — do NOT re-order or
// silently "simplify" them during maintenance. Copy from the vanilla
// source when it changes.
// ============================================================
export interface SystemEntry {
key: string;
label: string;
detail: string;
}
export const ROS_SYSTEMS: ReadonlyArray<SystemEntry> = [
{ key: 'constitutional', label: 'Constitutional', detail: 'fever, fatigue, weight loss/gain, appetite' },
{ key: 'eyes', label: 'Eyes', detail: 'vision changes, discharge, redness' },
{ key: 'ent', label: 'ENT', detail: 'ear pain, congestion, sore throat, mouth sores' },
{ key: 'cardiovascular', label: 'Cardiovascular', detail: 'chest pain, palpitations, murmur' },
{ key: 'respiratory', label: 'Respiratory', detail: 'cough, wheeze, dyspnea' },
{ key: 'gastrointestinal', label: 'Gastrointestinal', detail: 'nausea, vomiting, diarrhea, constipation, abdominal pain' },
{ key: 'genitourinary', label: 'Genitourinary', detail: 'dysuria, frequency, discharge, menstrual' },
{ key: 'musculoskeletal', label: 'Musculoskeletal', detail: 'joint pain, swelling, gait issues, back pain' },
{ key: 'skin', label: 'Skin', detail: 'rash, lesions, itch, hair/nail changes' },
{ key: 'neurological', label: 'Neurological', detail: 'headache, dizziness, seizures, numbness' },
{ key: 'psychiatric', label: 'Psychiatric', detail: 'mood, anxiety, sleep, behavior changes' },
{ key: 'endocrine', label: 'Endocrine', detail: 'polyuria/polydipsia, heat/cold intolerance, growth concerns' },
{ key: 'hematologic', label: 'Hematologic/Lymphatic', detail: 'bruising, bleeding, lymph nodes' },
{ key: 'allergic', label: 'Allergic/Immunologic', detail: 'allergic reactions, frequent infections' },
{ key: 'developmental', label: 'Developmental/Behavioral', detail: 'milestones, school, social' },
];
export const PE_SYSTEMS: ReadonlyArray<SystemEntry> = [
{ key: 'general', label: 'General Appearance', detail: 'WDWN, alert, NAD, etc.' },
{ key: 'peEyes', label: 'Eyes', detail: 'PERRL, conjunctiva, EOM' },
{ key: 'ears', label: 'Ears', detail: 'TMs, canals, hearing' },
{ key: 'nose', label: 'Nose', detail: 'mucosa, septum, turbinates' },
{ key: 'mouthThroat', label: 'Mouth/Throat', detail: 'lips, gums, teeth, tonsils, pharynx' },
{ key: 'neck', label: 'Neck', detail: 'lymph nodes, thyroid, masses' },
{ key: 'chestLungs', label: 'Chest/Lungs', detail: 'air entry, wheezing, retractions' },
{ key: 'peCv', label: 'Cardiovascular', detail: 'S1/S2, murmur, pulses, cap refill' },
{ key: 'abdomen', label: 'Abdomen', detail: 'soft, tenderness, organomegaly, BS' },
{ key: 'guTanner', label: 'Genitourinary/Tanner', detail: 'Tanner stage, genitalia, hernias' },
{ key: 'msk', label: 'Musculoskeletal', detail: 'strength, ROM, gait, spine' },
{ key: 'extremities', label: 'Extremities', detail: 'tone, reflexes, clubbing, edema' },
{ key: 'peSkin', label: 'Skin', detail: 'rashes, lesions, cafe au lait, bruises' },
{ key: 'peNeuro', label: 'Neurological', detail: 'CN intact, DTRs, coordination' },
{ key: 'lymphatic', label: 'Lymphatic', detail: 'cervical, axillary, inguinal nodes' },
{ key: 'breast', label: 'Breast', detail: 'if indicated by age/Tanner' },
{ key: 'pePsych', label: 'Psychiatric', detail: 'affect, mood, behavior during exam' },
];
export interface DxEntry { code: string; name: string }
export const COMMON_DX: ReadonlyArray<DxEntry> = [
{ code: 'Z00.129', name: 'Routine child health exam, no abnormal findings' },
{ code: 'Z00.121', name: 'Routine child health exam, with abnormal findings' },
{ code: 'J06.9', name: 'Upper Respiratory Infection, NOS' },
{ code: 'J02.9', name: 'Acute pharyngitis, NOS' },
{ code: 'J03.90', name: 'Acute tonsillitis, NOS' },
{ code: 'H66.90', name: 'Otitis media, NOS' },
{ code: 'J20.9', name: 'Acute bronchitis' },
{ code: 'J45.20', name: 'Mild intermittent asthma, uncomplicated' },
{ code: 'J45.30', name: 'Mild persistent asthma' },
{ code: 'A09', name: 'Gastroenteritis/colitis' },
{ code: 'L20.9', name: 'Atopic dermatitis, NOS' },
{ code: 'L30.9', name: 'Dermatitis NOS' },
{ code: 'R50.9', name: 'Fever NOS' },
{ code: 'R10.9', name: 'Abdominal pain NOS' },
{ code: 'R05.9', name: 'Cough' },
{ code: 'B34.9', name: 'Viral infection NOS' },
{ code: 'Z23', name: 'Immunization encounter' },
{ code: 'Z41.9', name: 'Procedure for non-remedial reason' },
{ code: 'K21.0', name: 'GERD with esophagitis' },
{ code: 'K21.9', name: 'GERD without esophagitis' },
{ code: 'F90.0', name: 'ADHD, predominantly inattentive' },
{ code: 'F90.2', name: 'ADHD, combined type' },
{ code: 'F41.1', name: 'Generalized anxiety disorder' },
{ code: 'F32.9', name: 'Major depressive episode NOS' },
{ code: 'E11.9', name: 'Type 2 diabetes' },
{ code: 'E03.9', name: 'Hypothyroidism NOS' },
{ code: 'M54.5', name: 'Low back pain' },
{ code: 'G43.909', name: 'Migraine NOS' },
];
// Per-system status — undefined/'' = Not Reviewed → omit when formatted.
export type RosStatus = '' | 'wnl' | 'abnormal' | 'notrev';
export interface RosCell { status?: RosStatus; note?: string }
export type RosData = Record<string, RosCell>;
// Format ROS/PE for the AI. Mirrors window.formatRosForAI exactly —
// tells the AI which systems to expand with pertinent negatives vs
// which to treat as abnormal findings to describe.
export function formatRosForAI(
systems: ReadonlyArray<SystemEntry>,
data: RosData,
heading: string,
): string {
const lines: string[] = [heading + ':'];
let hasAny = false;
for (const sys of systems) {
const cell = data[sys.key] || {};
if (!cell.status) continue;
hasAny = true;
if (cell.status === 'wnl') {
lines.push(' ' + sys.label + ': NORMAL [domain: ' + sys.detail + ']');
} else if (cell.status === 'abnormal') {
lines.push(' ' + sys.label + ': ABNORMAL' + (cell.note ? ' — ' + cell.note : '') + ' [domain: ' + sys.detail + ']');
} else {
lines.push(' ' + sys.label + ': Not reviewed');
}
}
return hasAny ? lines.join('\n') : '';
}
// Format diagnoses list for the AI. Mirrors window.formatDxForAI.
export function formatDxForAI(dx: ReadonlyArray<DxEntry>, freetext?: string): string {
const lines: string[] = [];
for (const d of dx) {
lines.push((d.code ? d.code + ' ' : '') + d.name);
}
const t = (freetext || '').trim();
if (t) lines.push(t);
return lines.length ? 'Diagnoses:\n' + lines.map((l) => ' - ' + l).join('\n') : '';
}