Biggest data port of the migration so far. PE_DATA is the 1000+ line
age-group × system × component × step hierarchy driving the pediatric
physical-exam checklist; every entry, pearl, significance note, and
abnormal-hint array is now available in the React tree.
client/src/data/pe-data.ts — verbatim port
Extracted lines 316-1334 of public/js/peGuide.js with awk/sed, then
wrapped in TS types. Every byte of the data body is byte-identical
to the vanilla source. Added interfaces:
PeStep { label, method, normal }
PeComponent { name, steps[], abnormalHints[], pearl?, significance? }
PeSystem { overview, components[] }
PeAgeGroup { label, msk, neuro, resp, cv }
…plus AGE_GROUP_ORDER / SYSTEM_ORDER / SYSTEM_LABELS canonical
orderings for the UI.
client/src/data/pe-data.test.ts — parity lock
Vitest suite that asserts every count captured from the vanilla
source so any accidental drop surfaces as a red test:
• 6 age groups × 4 systems
• 103 components total
• 27 pearl entries
• 23 significance entries
• per-cell component counts (e.g. toddler.neuro = 7, adolescent.cv = 5)
Counts captured 2026-04-24 against peGuide.js commit 313ba7f.
client/src/pages/PeGuide.tsx — full viewer (replaces legacy-link stub)
• Age-group pills (6) + system pills (4) drive the visible section
• Overview banner per combination
• CV system shows APTM legend + cardiac sounds library + innocent
murmurs reference (unchanged clinical content from the earlier
commit that added the scales/sounds file)
• Resp system shows the respiratory sounds library
• Collapsible grading-scales reference pulls from SYSTEM_SCALES
• Component checklist: per-step Normal / Abnormal toggle, abnormal-
hint list, pearl + significance callouts
• Mark-all-normal + Reset shortcuts
• Generate Exam Report posts the full step payload to
/api/generate-pe-narrative, renders the returned narrative inline
• No more "Open checklist in legacy viewer" amber banner — the
React port now does the whole thing
e2e/tests/peguide-react.spec.js
Age-group pills, system pills, overview rewrite on age change,
CV/resp system-specific reference panels, mark-all-normal + summary,
and a mocked /api/generate-pe-narrative round-trip.
Client tsc -b + vite build clean. Bundle 580.30 kB / 166.08 kB gz
(up ~100 kB from the shell-only port — the 1000-line PE_DATA is the
bulk; acceptable for the clinical reference data it surfaces).
471 lines
18 KiB
TypeScript
471 lines
18 KiB
TypeScript
// ============================================================
|
||
// PHYSICAL EXAM GUIDE — full React port.
|
||
//
|
||
// Renders:
|
||
// • Age-group + system pills (6 × 4 = 24 combinations)
|
||
// • System overview banner
|
||
// • CV system extras: APTM legend, cardiac sounds, innocent murmurs
|
||
// • Resp system extras: respiratory sounds library
|
||
// • Collapsible grading-scales reference (system-scoped)
|
||
// • Component checklist with per-step normal / abnormal / (unset)
|
||
// toggle, abnormal-hints hint list, pearl + significance callouts
|
||
// • Patient age / gender + model inputs
|
||
// • Generate Exam Report → POST /api/generate-pe-narrative
|
||
//
|
||
// PE_DATA is the full hierarchy ported verbatim from vanilla
|
||
// peGuide.js (see client/src/data/pe-data.ts). Clinical reference
|
||
// libraries (scales, APTM, sound files) live in pe-guide.ts.
|
||
// ============================================================
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { useMutation } from '@tanstack/react-query';
|
||
import { api } from '@/lib/api';
|
||
import type { PeNarrativeOk } from '@/shared/types';
|
||
import {
|
||
PE_DATA,
|
||
AGE_GROUP_ORDER,
|
||
SYSTEM_ORDER,
|
||
SYSTEM_LABELS,
|
||
type PeComponent,
|
||
type PeStep,
|
||
} from '@/data/pe-data';
|
||
import {
|
||
SCALES,
|
||
SYSTEM_SCALES,
|
||
APTM_LEGEND,
|
||
INNOCENT_MURMURS,
|
||
RESP_SOUNDS,
|
||
CARDIAC_SOUNDS,
|
||
type ScaleDef,
|
||
type SoundEntry,
|
||
} from '@/data/pe-guide';
|
||
|
||
const card = 'rounded-lg border border-border bg-card p-5 space-y-3';
|
||
const pill = 'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer';
|
||
const btnPrimary = 'rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50';
|
||
const btnGhost = 'rounded-md border border-border bg-background px-3 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50';
|
||
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
|
||
|
||
type StepStatus = 'normal' | 'abnormal' | null;
|
||
|
||
// Key used to identify a step in the status map across age-group / system.
|
||
function stepKey(age: string, sys: string, componentIdx: number, stepIdx: number) {
|
||
return `${age}/${sys}/${componentIdx}/${stepIdx}`;
|
||
}
|
||
|
||
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
|
||
return (
|
||
<section className="rounded-md border border-border bg-background p-3" data-testid={'scale-' + id}>
|
||
<h4 className="text-sm font-semibold mb-2">{scale.title}</h4>
|
||
<table className="w-full text-xs">
|
||
<tbody>
|
||
{scale.rows.map(([labelText, desc], i) => (
|
||
<tr key={i} className="border-b border-border last:border-0">
|
||
<td className="py-1 pr-3 font-mono font-semibold whitespace-nowrap">{labelText}</td>
|
||
<td className="py-1 text-muted-foreground">{desc}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SoundCard({ entry }: { entry: SoundEntry }) {
|
||
return (
|
||
<div className="rounded-md border border-border bg-background p-3 space-y-2" data-testid={'sound-' + entry.key}>
|
||
<div className="text-sm font-semibold">{entry.title}</div>
|
||
<audio controls preload="none" className="w-full">
|
||
<source src={entry.src} />
|
||
</audio>
|
||
<div className="text-xs space-y-0.5 text-muted-foreground">
|
||
<div><span className="font-semibold">Where:</span> {entry.where}</div>
|
||
{entry.rate && <div><span className="font-semibold">Rate:</span> {entry.rate}</div>}
|
||
<div><span className="font-semibold">Features:</span> {entry.features}</div>
|
||
<div><span className="font-semibold">Clinical:</span> {entry.clinical}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StepRow({
|
||
step,
|
||
status,
|
||
onStatus,
|
||
}: {
|
||
step: PeStep;
|
||
status: StepStatus;
|
||
onStatus: (next: StepStatus) => void;
|
||
}) {
|
||
const base = 'text-xs font-medium px-2 py-1 rounded border';
|
||
return (
|
||
<div className="flex items-start gap-2 py-2 border-b border-border last:border-0">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-sm font-medium">{step.label}</div>
|
||
<div className="text-xs text-muted-foreground mt-0.5">
|
||
<span className="font-semibold uppercase tracking-wide">Method:</span> {step.method}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">
|
||
<span className="font-semibold uppercase tracking-wide">Normal:</span> {step.normal}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col sm:flex-row gap-1 flex-shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onStatus(status === 'normal' ? null : 'normal')}
|
||
className={
|
||
base + ' ' +
|
||
(status === 'normal'
|
||
? 'bg-green-600 text-white border-green-600'
|
||
: 'border-green-600 text-green-700 hover:bg-green-50 dark:hover:bg-green-950/30')
|
||
}
|
||
>
|
||
Normal
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => onStatus(status === 'abnormal' ? null : 'abnormal')}
|
||
className={
|
||
base + ' ' +
|
||
(status === 'abnormal'
|
||
? 'bg-destructive text-white border-destructive'
|
||
: 'border-destructive text-destructive hover:bg-red-50 dark:hover:bg-red-950/30')
|
||
}
|
||
>
|
||
Abnormal
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ComponentCard({
|
||
age,
|
||
sys,
|
||
idx,
|
||
comp,
|
||
getStatus,
|
||
setStatus,
|
||
}: {
|
||
age: string;
|
||
sys: string;
|
||
idx: number;
|
||
comp: PeComponent;
|
||
getStatus: (k: string) => StepStatus;
|
||
setStatus: (k: string, next: StepStatus) => void;
|
||
}) {
|
||
return (
|
||
<div className={card} data-testid={`pe-component-${age}-${sys}-${idx}`}>
|
||
<h3 className="text-base font-semibold">{comp.name}</h3>
|
||
<div>
|
||
{comp.steps.map((step, si) => {
|
||
const k = stepKey(age, sys, idx, si);
|
||
return (
|
||
<StepRow
|
||
key={si}
|
||
step={step}
|
||
status={getStatus(k)}
|
||
onStatus={(next) => setStatus(k, next)}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
{comp.abnormalHints.length > 0 && (
|
||
<div className="rounded-md bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 p-3">
|
||
<div className="text-xs font-semibold uppercase tracking-wide text-destructive mb-1">Watch for</div>
|
||
<ul className="list-disc pl-5 text-xs text-red-900 dark:text-red-200 space-y-0.5">
|
||
{comp.abnormalHints.map((h, hi) => <li key={hi}>{h}</li>)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{comp.pearl && (
|
||
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-300 dark:border-amber-800 p-3 text-xs text-amber-900 dark:text-amber-100">
|
||
<span className="font-semibold uppercase tracking-wide">Pearl:</span> {comp.pearl}
|
||
</div>
|
||
)}
|
||
{comp.significance && (
|
||
<div className="rounded-md bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-900 p-3 text-xs text-sky-900 dark:text-sky-100">
|
||
<span className="font-semibold uppercase tracking-wide">Significance:</span> {comp.significance}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function PeGuide() {
|
||
const [age, setAge] = useState<(typeof AGE_GROUP_ORDER)[number]>('toddler');
|
||
const [sys, setSys] = useState<(typeof SYSTEM_ORDER)[number]>('msk');
|
||
const [patientAge, setPatientAge] = useState('');
|
||
const [patientGender, setPatientGender] = useState('');
|
||
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
|
||
const [statusMap, setStatusMap] = useState<Record<string, StepStatus>>({});
|
||
const [narrative, setNarrative] = useState<string | null>(null);
|
||
|
||
const group = PE_DATA[age];
|
||
const section = group[sys];
|
||
|
||
const generate = useMutation({
|
||
mutationFn: (body: unknown) => api.post<PeNarrativeOk>('/api/generate-pe-narrative', body),
|
||
onSuccess: (data) => setNarrative(data.narrative),
|
||
onError: (e: Error) => setNarrative('Generation failed: ' + e.message),
|
||
});
|
||
|
||
const summary = useMemo(() => {
|
||
let normal = 0, abnormal = 0, notAssessed = 0;
|
||
section.components.forEach((c, ci) =>
|
||
c.steps.forEach((_, si) => {
|
||
const k = stepKey(age, sys, ci, si);
|
||
const v = statusMap[k] ?? null;
|
||
if (v === 'normal') normal++;
|
||
else if (v === 'abnormal') abnormal++;
|
||
else notAssessed++;
|
||
}),
|
||
);
|
||
return { normal, abnormal, notAssessed };
|
||
}, [age, sys, section, statusMap]);
|
||
|
||
function reset() {
|
||
// Only clear the current system's entries, not all state.
|
||
setStatusMap((prev) => {
|
||
const next = { ...prev };
|
||
section.components.forEach((c, ci) =>
|
||
c.steps.forEach((_, si) => { delete next[stepKey(age, sys, ci, si)]; }),
|
||
);
|
||
return next;
|
||
});
|
||
setNarrative(null);
|
||
}
|
||
|
||
function setAllNormal() {
|
||
setStatusMap((prev) => {
|
||
const next = { ...prev };
|
||
section.components.forEach((c, ci) =>
|
||
c.steps.forEach((_, si) => { next[stepKey(age, sys, ci, si)] = 'normal'; }),
|
||
);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function onGenerate() {
|
||
setNarrative(null);
|
||
const steps: Array<{ component: string; label: string; method: string; normal: string; status: StepStatus; note?: string }> = [];
|
||
section.components.forEach((c, ci) =>
|
||
c.steps.forEach((st, si) => {
|
||
steps.push({
|
||
component: c.name,
|
||
label: st.label,
|
||
method: st.method,
|
||
normal: st.normal,
|
||
status: statusMap[stepKey(age, sys, ci, si)] ?? null,
|
||
});
|
||
}),
|
||
);
|
||
generate.mutate({
|
||
steps,
|
||
ageGroup: age,
|
||
system: sys,
|
||
patientAge: patientAge || undefined,
|
||
patientGender: patientGender || undefined,
|
||
format,
|
||
});
|
||
}
|
||
|
||
const totalAssessed = summary.normal + summary.abnormal;
|
||
|
||
return (
|
||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||
<header>
|
||
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Age-group and system-specific exam checklist with abnormal-finding hints. Toggle normal / abnormal
|
||
on each step, then generate a narrative for your note.
|
||
</p>
|
||
</header>
|
||
|
||
{/* Age-group pills */}
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Age group</div>
|
||
<div className="flex flex-wrap gap-2" data-testid="pe-age-group-pills">
|
||
{AGE_GROUP_ORDER.map((g) => (
|
||
<button
|
||
key={g}
|
||
type="button"
|
||
onClick={() => setAge(g)}
|
||
className={pill + (age === g ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
|
||
data-testid={'pe-age-' + g}
|
||
>
|
||
{PE_DATA[g].label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* System pills */}
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">System</div>
|
||
<div className="flex flex-wrap gap-2" data-testid="pe-system-pills">
|
||
{SYSTEM_ORDER.map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => setSys(s)}
|
||
className={pill + (sys === s ? ' bg-primary text-primary-foreground border-primary' : ' bg-muted hover:bg-muted/80 border-border')}
|
||
data-testid={'pe-system-' + s}
|
||
>
|
||
{SYSTEM_LABELS[s]}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Overview */}
|
||
<section className={card + ' border-l-4 border-l-primary'} data-testid="pe-overview">
|
||
<h2 className="text-lg font-semibold">{group.label} — {SYSTEM_LABELS[sys]}</h2>
|
||
<p className="text-sm text-muted-foreground">{section.overview}</p>
|
||
</section>
|
||
|
||
{/* System-specific references */}
|
||
{sys === 'cv' && (
|
||
<section className={card} data-testid="pe-cv-aptm">
|
||
<h3 className="text-base font-semibold">Auscultation landmarks (APTM + Erb's)</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{APTM_LEGEND.map((p) => (
|
||
<div key={p.letter} className="flex gap-3 items-start rounded-md border border-border p-3">
|
||
<div
|
||
className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white flex-shrink-0"
|
||
style={{ background: p.color }}
|
||
>
|
||
{p.letter}
|
||
</div>
|
||
<div className="min-w-0 text-sm">
|
||
<div className="font-semibold">{p.title}</div>
|
||
<div className="text-xs text-muted-foreground">{p.location}</div>
|
||
<div className="text-xs mt-1"><strong>Listen for:</strong> {p.listen}</div>
|
||
{p.innocent && <div className="text-xs text-green-700 dark:text-green-300 mt-1"><em>Innocent:</em> {p.innocent}</div>}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<h3 className="text-base font-semibold mt-3">Cardiac sounds library</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||
</div>
|
||
<h3 className="text-base font-semibold mt-3">Classic innocent murmurs</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{INNOCENT_MURMURS.map((m) => (
|
||
<div key={m.name} className="rounded-md border border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/30 p-3 text-sm space-y-1">
|
||
<div className="font-semibold">{m.name}</div>
|
||
<div className="text-xs text-muted-foreground">Age: {m.age} · Location: {m.location}</div>
|
||
<div className="text-xs"><strong>Sound:</strong> {m.character}</div>
|
||
<div className="text-xs"><strong>Confirm innocent:</strong> {m.confirm}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{sys === 'resp' && (
|
||
<section className={card} data-testid="pe-resp-sounds">
|
||
<h3 className="text-base font-semibold">Respiratory sounds library</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{/* Grading scales (system-scoped, collapsible) */}
|
||
{SYSTEM_SCALES[sys] && SYSTEM_SCALES[sys].length > 0 && (
|
||
<details className={card} data-testid="pe-scales">
|
||
<summary className="cursor-pointer font-semibold text-sm">Grading scales & reference</summary>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
|
||
{SYSTEM_SCALES[sys].map((sk: string) => {
|
||
const scale = SCALES[sk];
|
||
if (!scale) return null;
|
||
return <ScaleCard key={sk} id={sk} scale={scale} />;
|
||
})}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{/* Checklist */}
|
||
<section className="space-y-3" data-testid="pe-checklist">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<h2 className="text-lg font-semibold">Exam checklist</h2>
|
||
<div className="text-xs text-muted-foreground flex items-center gap-3">
|
||
<span className="text-green-600">{summary.normal} normal</span>
|
||
<span className="text-destructive">{summary.abnormal} abnormal</span>
|
||
<span>{summary.notAssessed} not assessed</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<button type="button" onClick={setAllNormal} className={btnGhost} data-testid="btn-pe-all-normal">
|
||
Mark all normal
|
||
</button>
|
||
<button type="button" onClick={reset} className={btnGhost} data-testid="btn-pe-reset">
|
||
Reset
|
||
</button>
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-3">
|
||
{section.components.map((c, ci) => (
|
||
<ComponentCard
|
||
key={ci}
|
||
age={age}
|
||
sys={sys}
|
||
idx={ci}
|
||
comp={c}
|
||
getStatus={(k) => statusMap[k] ?? null}
|
||
setStatus={(k, next) => setStatusMap((prev) => ({ ...prev, [k]: next }))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
{/* Generate narrative */}
|
||
<section className={card} data-testid="pe-generate">
|
||
<h2 className="text-lg font-semibold">Generate Exam Report</h2>
|
||
<p className="text-sm text-muted-foreground">Uses the statuses above + optional patient context.</p>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||
<input
|
||
className={input}
|
||
placeholder="Patient age (e.g. 3y)"
|
||
value={patientAge}
|
||
onChange={(e) => setPatientAge(e.target.value)}
|
||
/>
|
||
<input
|
||
className={input}
|
||
placeholder="Patient gender (optional)"
|
||
value={patientGender}
|
||
onChange={(e) => setPatientGender(e.target.value)}
|
||
/>
|
||
<select
|
||
className={input}
|
||
value={format}
|
||
onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}
|
||
>
|
||
<option value="narrative">Narrative</option>
|
||
<option value="list">List</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
className={btnPrimary}
|
||
onClick={onGenerate}
|
||
disabled={generate.isPending || totalAssessed === 0}
|
||
data-testid="btn-pe-generate"
|
||
>
|
||
{generate.isPending ? 'Generating…' : 'Generate Exam Report'}
|
||
</button>
|
||
{totalAssessed === 0 && (
|
||
<span className="text-xs text-muted-foreground">Mark at least one step before generating.</span>
|
||
)}
|
||
</div>
|
||
{narrative && (
|
||
<div className="rounded-md border border-border bg-muted/40 p-3 whitespace-pre-wrap text-sm" data-testid="pe-narrative">
|
||
{narrative}
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|