feat(client): port PE Guide reference libraries + rescue untracked FAQ data

Two things in one commit because they're coupled by a gitignore fix:
(1) PE Guide minimum-viable port, (2) a pre-existing bug where
client/src/data/faq.ts was silently gitignored and never committed.

.gitignore — narrow scope
  Changed `data/` to `/data/`. The old rule matched every nested
  `data/` directory in the tree, including client/src/data/, which
  meant faq.ts from the FAQ port (commit c0038da) never landed in
  git — the Faq page built locally only because the file existed
  on the dev machine. A fresh clone or CI build would fail the
  Vite build at '@/data/faq'. The narrowed rule still ignores the
  runtime DB directory at repo root while allowing project data
  modules to be tracked. This commit also commits the missing
  faq.ts so the FAQ page is actually buildable from git again.

client/src/data/pe-guide.ts
  Verbatim port of lines 23-311 of public/js/peGuide.js — the stable
  reference content:
    • SCALES (12 scales: MRC, DTR, plantar, Beighton, ATR, RR, SpO2,
      Silverman, Westley, Levine murmur, pulse amp, cap refill)
    • SYSTEM_SCALES (per-body-system scale mapping)
    • APTM_LEGEND (5 cardiac auscultation points)
    • INNOCENT_MURMURS (5 benign childhood murmurs)
    • RESP_SOUNDS (7 entries with /audio/respiratory/*.ogg paths)
    • CARDIAC_SOUNDS (6 entries with /audio/cardiac/* paths)
  Counts preserved exactly. Audio files stay under public/audio/ and
  are served unchanged.

What is NOT in this commit — on purpose
  PE_DATA (the ~1000-line age-group × system × component × step
  hierarchy) stays in the vanilla app. The migration checkpoint memory
  explicitly warns about the class of bug where an LLM silently drops
  entries from long clinical arrays. PE_DATA porting needs its own
  session with per-entry counts + visual diff against the vanilla
  source. An amber banner at the top of the React page links to
  /#peGuide (the legacy checklist viewer) so users still reach the
  full exam-step checklist + Generate-Exam-Report flow.

  Also skipped: the big inline APTM_SVG chest diagram. The letter
  legend (A/P/E/T/M) carries the clinical content; the pictorial
  SVG can land later without content risk.

client/src/pages/PeGuide.tsx — viewer
  Grid-of-cards layout: one card per scale, per APTM point, per
  innocent-murmur, and per sound entry. Sound cards use native
  <audio controls> so the browser does the usual play/pause/seek —
  no custom player. data-testid hooks throughout for the spec.

e2e/tests/peguide-react.spec.js — four smoke tests:
  all 12 scales render (this is the count that would fail loudly if
  someone trimmed SCALES later), APTM has all 5 letters, sound
  libraries have the exact respiratory + cardiac keys, and the
  legacy-viewer link is present.

Client tsc -b + vite build clean. Bundle 452.91 kB / 129.35 kB gz.
This commit is contained in:
Daniel 2026-04-23 23:57:11 +02:00
parent 1798b71a97
commit 104c1c5f7e
11 changed files with 671 additions and 8 deletions

2
.gitignore vendored
View file

@ -2,7 +2,7 @@ node_modules/
.env
.env.local
.env.production
data/
/data/
!public/data/
*.db
*.db-journal

View file

@ -14,6 +14,7 @@ import VaxSchedule from '@/pages/VaxSchedule';
import Catchup from '@/pages/Catchup';
import Settings from '@/pages/Settings';
import Learning from '@/pages/Learning';
import PeGuide from '@/pages/PeGuide';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -59,6 +60,7 @@ export default function App() {
<Route path="/extensions" element={<Extensions />} />
<Route path="/settings" element={<Settings />} />
<Route path="/learning" element={<Learning />} />
<Route path="/peguide" element={<PeGuide />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}
<Route path="*" element={<Navigate to="/" replace />} />

View file

@ -41,7 +41,7 @@ const NAV: NavGroup[] = [
items: [
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
{ to: '/peguide', label: 'Physical Exam Guide', available: false },
{ to: '/peguide', label: 'Physical Exam Guide', available: true },
{ to: '/bedside', label: 'Bedside', available: false },
{ to: '/calculators', label: 'Calculators', available: false },
{ to: '/extensions', label: 'Pagers & Extensions', available: true },

150
client/src/data/faq.ts Normal file
View file

@ -0,0 +1,150 @@
export const FAQ_DATA = [
{
"section": "Getting Started",
"items": [
{
"q": "What is Pediatric AI Scribe?",
"a": "Pediatric AI Scribe is an AI-powered clinical documentation tool designed specifically for pediatric medicine. It helps physicians generate structured clinical notes from voice recordings or typed text, saving time on documentation so you can focus on patient care. It supports HPIs, SOAP notes, hospital courses, chart reviews, well visits, sick visits, developmental milestone assessments, and more."
},
{
"q": "How do I create my first note?",
"a": "The easiest way to start is with Live Encounter: Go to the Encounter tab Enter the patient's age and gender Click Start Recording and speak naturally during your patient encounter Click Stop when done &mdash; the audio is transcribed automatically Click Generate HPI to create a structured note Edit the note as needed, then Copy to paste into your EHR"
},
{
"q": "Can I type or paste notes instead of recording?",
"a": "Yes. Every transcript box is editable. You can type directly, paste from another source, or combine typed text with a recording. The AI works with whatever text is in the transcript area when you click Generate."
},
{
"q": "What types of notes can I generate?",
"a": "HPI &mdash; from live encounters or dictation, with OLDCARTS structure SOAP Notes &mdash; full SOAP or subjective-only from dictation Hospital Course &mdash; from progress notes, in prose, day-by-day, or organ-system format Chart Review &mdash; summarize outpatient, subspecialty, or ED visits for precharting Well Visit &mdash; complete preventive care notes with SSHADESS, milestones, and vaccines Sick Visit &mdash; quick documentation with auto-suggested ROS and PE Milestone Assessment &mdash; developmental narrative from selected milestones"
}
]
},
{
"section": "AI & Models",
"items": [
{
"q": "What AI model should I use?",
"a": "Each tab has a model selector dropdown. All available models have been tested and configured by your administrator for clinical documentation quality. They are routed through HIPAA-compliant providers with signed Business Associate Agreements (BAAs). All models are capable of generating accurate clinical notes. If you are unsure which to pick, start with the default. You can experiment with different models and see which output style you prefer &mdash; some may be faster, some more detailed, some more concise. You can choose a different model per tab depending on the task."
},
{
"q": "Does the AI learn from my edits?",
"a": "Yes. The app uses a correction tracking system inspired by Dragon Medical's adaptive learning. Here is how it works: When the AI generates a note, the original output is stored in memory You edit the note to match your preferred style &mdash; fix phrasing, add details, restructure sections When you click Save, the app detects what you changed and stores the correction On future notes, your past corrections are included as style hints so the AI adapts to your documentation preferences The more you use the app and save your edits, the better the AI gets at matching your style. You can view and manage your stored corrections in Settings &gt; AI Corrections. Note: Corrections are applied as gentle suggestions, not strict rules. The AI prioritizes clinical accuracy over style matching."
},
{
"q": "Can I customize the AI's prompts?",
"a": "Administrators can edit all AI prompts from the Admin Panel &gt; Settings &gt; Prompts section. This lets you adjust the instructions the AI follows for each note type without changing any code. Changes take effect immediately."
},
{
"q": "What does the \"Refine\" button do?",
"a": "After generating a note, you can give the AI plain-language instructions to modify it. For example: \"Make it shorter\" \"Add that the patient has a history of asthma\" \"Summarize the labs\" \"Change the assessment to include bronchiolitis\" The AI references both its current output and your original source material (transcript, pasted notes, labs) when refining, so it can look up details from the original input."
}
]
},
{
"section": "Voice & Transcription",
"items": [
{
"q": "How does voice transcription work?",
"a": "When you stop recording, the audio is sent to a speech-to-text service that converts it to text. The app supports multiple transcription providers including Whisper, Deepgram, and Google Gemini. Your administrator configures which provider is used. You will see a blue status bar at the top while transcription is in progress. You can continue working on the page while it processes."
},
{
"q": "What is Browser Whisper?",
"a": "Browser Whisper runs the Whisper AI model entirely in your browser using WebAssembly. Your audio never leaves your device, making it the most private transcription option. You can enable it in Settings &gt; Browser Whisper. It works offline and is HIPAA-safe since no data is transmitted. The tradeoff is that it is slower than cloud-based transcription and requires downloading the model (~40&ndash;240 MB) on first use."
},
{
"q": "Can I use the app on my phone?",
"a": "Yes. The app is a Progressive Web App (PWA) that works in any modern browser. On mobile: Open the app in Chrome or Safari Tap \"Add to Home Screen\" to install it as a standalone app Recording works in the foreground, but audio stops if you lock the screen or switch apps on iOS and most Android devices &mdash; this is a browser limitation, not specific to this app On desktop, recording continues normally when the browser is minimized or in the background."
},
{
"q": "What happens if transcription fails?",
"a": "If transcription fails, your audio is automatically backed up to the server for 24 hours. You can retry transcription from Settings &gt; Audio Backups. If browser speech recognition was active during recording, the live transcript is preserved as a fallback."
},
{
"q": "Can the AI read my notes aloud?",
"a": "Yes. Click the Read button on any generated note to hear it spoken aloud. This uses text-to-speech (TTS) powered by Google, OpenAI, or ElevenLabs depending on your setup. You can choose your preferred voice in Settings &gt; Voice Preferences."
}
]
},
{
"section": "Saving & Export",
"items": [
{
"q": "Are my encounters saved?",
"a": "You can save encounters using the Save button at the top of each tab. Saved encounters include the transcript, generated note, and patient label. You can reload them later using the Load button. Saved encounters are automatically deleted after 7 days (configurable by your administrator). This is intentional &mdash; the app is a documentation tool, not a medical record system. Copy your final notes to your EHR for permanent storage."
},
{
"q": "How do I export notes?",
"a": "Copy &mdash; one-click copy to clipboard, ready to paste into any EHR Nextcloud &mdash; export directly to your Nextcloud instance (configure in Settings) Documents &mdash; upload files to S3-compatible storage from Settings All generated text is plain text with no markdown formatting, designed to paste cleanly into any EHR system."
}
]
},
{
"section": "Privacy & Security",
"items": [
{
"q": "Is my patient data safe?",
"a": "The app is designed with clinical privacy in mind: All connections use HTTPS/TLS encryption Audio and encounter data are temporary &mdash; auto-deleted within hours or days No patient data is stored long-term on the server Every action is audit-logged (who accessed what, when) Two-factor authentication (2FA) and session management are available Browser Whisper keeps audio entirely on your device For HIPAA compliance, ensure your administrator has configured a BAA-covered AI provider (such as AWS Bedrock, Google Vertex AI, or Azure OpenAI)."
},
{
"q": "What is two-factor authentication (2FA)?",
"a": "2FA adds an extra layer of security to your account. After entering your password, you also enter a 6-digit code from an authenticator app (like Google Authenticator or Authy). Enable it in Settings &gt; Two-Factor Authentication."
},
{
"q": "How do I manage my active sessions?",
"a": "Go to Settings &gt; Active Sessions to see all devices where you are logged in. You can revoke any session individually or click Revoke All Other Sessions to log out every other device. Your current session is highlighted and cannot be revoked from this screen &mdash; use the Logout button instead."
},
{
"q": "What happens when I change my password?",
"a": "When you change your password in Settings &gt; Change Password, all other active sessions are automatically logged out for security. Only your current session remains active. The app also checks if your new password has appeared in known data breaches and warns you (but does not prevent you from using it)."
}
]
},
{
"section": "Well Visit & Sick Visit",
"items": [
{
"q": "How does the Well Visit tab work?",
"a": "The Well Visit tab follows the AAP Bright Futures periodicity schedule. It includes: Visit by Age &mdash; recommended screenings, vaccines, and anticipatory guidance for each visit age Milestones &mdash; developmental milestone tracker from birth through 11 years across multiple domains SSHADESS &mdash; adolescent psychosocial assessment (Strengths, School, Home, Activities, Drugs, Emotions, Sexuality, Safety) for ages 12+ Visit Note &mdash; generates a complete well visit note combining ROS, PE, milestones, and SSHADESS data"
},
{
"q": "What do the WNL / Abnormal / Not Reviewed buttons do?",
"a": "In the ROS and Physical Exam sections, each system has three options: WNL / Normal &mdash; within normal limits, no concerns Abnormal &mdash; a text box appears so you can describe the finding Not Reviewed / Not Examined &mdash; explicitly not assessed Use All WNL to quickly mark everything normal, then click individual systems to change specific ones. Use Clear to reset all selections."
}
]
},
{
"section": "Learning Hub",
"items": [
{
"q": "What is the Learning Hub?",
"a": "The Learning Hub is an educational platform integrated into the app. It contains articles, clinical pearls, quizzes, and slide presentations created by moderators and administrators. You can browse by category, search content, and take quizzes to test your knowledge."
},
{
"q": "How do quizzes work?",
"a": "Quizzes include multiple-choice, multi-select, and true/false questions. After submitting your answers, you see your score along with explanations for each question. Your past attempts and scores are tracked so you can monitor your progress over time."
},
{
"q": "Can I create Learning Hub content?",
"a": "Moderators and administrators can create content using the CMS tab. You can write articles manually, or use AI to generate content from a topic description, uploaded PDFs, or files from Nextcloud. The CMS also supports Marp-based slide presentations with PPTX export."
}
]
},
{
"section": "Pediatric Calculators",
"items": [
{
"q": "What calculators are available?",
"a": "The Calculators tab includes clinical tools commonly used in pediatric practice: Blood Pressure Percentile &mdash; AAP 2017 guidelines using the Rosner quantile spline regression method. Requires age, sex, height, and BP. Provides exact systolic and diastolic percentiles adjusted for height, with AAP classification (Normal, Elevated, Stage 1, Stage 2). Includes definitions of hypertension and hypotension. BMI Percentile &mdash; CDC 2000 growth reference with extended obesity classification (Class 1, 2, 3 using % of 95th percentile). Shows BMI chart with percentile curves. Growth Charts &mdash; Visual percentile curves (3rd through 97th) with your patient plotted. Includes Weight-for-Age, Length/Height-for-Age (with mid-parental height), Head Circumference, Weight-for-Length, and Fenton preterm charts. Bilirubin &mdash; AAP 2022 phototherapy threshold calculator and Bhutani hour-specific nomogram with risk zone classification. Includes Nelson Table 137.1 risk factors. Vital Signs by Age &mdash; Harriet Lane reference table for HR, RR, BP, and weight by age from preterm through 18 years. Includes quick formulas for estimated weight, minimum SBP, ETT size, and maintenance fluids. Body Surface Area &mdash; Mosteller formula for BSA calculation. Weight-Based Dosing &mdash; Dose per kg with frequency, max dose cap, and volume calculation from concentration."
},
{
"q": "How accurate is the BP calculator?",
"a": "The BP calculator uses the same Rosner quantile spline regression method as the Baylor College of Medicine reference calculator. It computes exact percentiles (1st-99th) based on your patient's age, sex, and height using published regression coefficients. This is the same methodology underlying the AAP 2017 normative tables. Results are height-adjusted and clinically accurate."
},
{
"q": "What are the growth chart curves?",
"a": "The growth charts display WHO/CDC percentile curves (3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th percentiles) with your patient's measurement plotted as a blue dot. The 50th percentile is shown as a bold green line. Shaded bands show the normal range between symmetric percentiles. For Length/Height-for-Age, you can optionally enter both parents' heights to see the mid-parental target height range plotted on the chart."
}
]
}
];

290
client/src/data/pe-guide.ts Normal file
View file

@ -0,0 +1,290 @@
// ============================================================
// PE-GUIDE DATA — ported verbatim from public/js/peGuide.js
// (lines 23-311 of the vanilla file, as of commit before this one).
//
// This file ONLY contains the stable reference data:
// • SCALES — grading scales (MRC, DTR, Levine, Beighton, …)
// • SYSTEM_SCALES — which scales belong to which body system
// • APTM_LEGEND — the 5 cardiac auscultation points
// • INNOCENT_MURMURS — benign childhood murmurs
// • RESP_SOUNDS — respiratory sounds library (audio paths)
// • CARDIAC_SOUNDS — cardiac sounds library (audio paths)
//
// PE_DATA (the full age-group × system × component × step hierarchy,
// ~1000 lines) is intentionally NOT ported here. It holds clinically
// reviewed content and the migration checkpoint explicitly warns
// "An LLM will sometimes 'simplify' a long array — don't let that
// happen." PE_DATA port belongs in its own dedicated session with
// per-entry counts + visual diff verification against the vanilla
// source. Until that session, the React PE Guide surfaces the
// reference libraries below and links to the legacy viewer for
// exam-step checklists and narrative generation.
//
// Audio files stay in public/audio/respiratory/ and public/audio/cardiac/
// and are served unchanged from Express.
// ============================================================
export interface ScaleDef {
title: string;
icon: string;
rows: Array<[string, string]>;
}
export const SCALES: Record<string, ScaleDef> = {
mrc: {
title: 'MRC strength grade (05)',
icon: 'fa-hand-fist',
rows: [
['5', 'Normal power — holds against full resistance'],
['4', 'Reduced — moves against gravity + some resistance'],
['3', 'Moves against gravity only (no added resistance)'],
['2', 'Full range with gravity eliminated (horizontal plane)'],
['1', 'Flicker / trace contraction, no joint movement'],
['0', 'No contraction'],
],
},
dtr: {
title: 'Deep-tendon reflex grade (04+)',
icon: 'fa-circle-dot',
rows: [
['0', 'Absent'],
['1+', 'Hypoactive — trace, only with reinforcement'],
['2+', 'Normal'],
['3+', 'Brisk — may still be normal in anxious patients'],
['4+', 'Hyperactive with sustained clonus — always abnormal'],
],
},
plantar: {
title: 'Plantar response (Babinski)',
icon: 'fa-shoe-prints',
rows: [
['Down-going', 'Normal in anyone ≥ 2 years'],
['Up-going', 'Normal < 2 years; abnormal after — UMN lesion'],
['Asymmetric', 'Always abnormal at any age'],
],
},
beighton: {
title: 'Beighton hypermobility score (09)',
icon: 'fa-hands',
rows: [
['≤ 3', 'Normal flexibility'],
['4', 'Borderline — consider in context'],
['≥ 5', 'Hypermobility spectrum; screen for hEDS if other features present'],
],
},
atr: {
title: 'Scoliometer — angle of trunk rotation',
icon: 'fa-ruler',
rows: [
['< 5°', 'Normal, no follow-up'],
['56°', 'Borderline — re-check at each visit'],
['≥ 7°', 'Refer for PA/lateral spine x-ray + orthopedic evaluation'],
],
},
rr: {
title: 'Respiratory rate — upper limit by age (awake)',
icon: 'fa-lungs',
rows: [
['Newborn', '≤ 60 /min'],
['< 2 months', '≤ 60 /min (WHO tachypnea cutoff)'],
['212 months', '≤ 50 /min (WHO tachypnea cutoff)'],
['15 years', '≤ 40 /min (WHO tachypnea cutoff)'],
['611 years', '≤ 30 /min'],
['≥ 12 years', '≤ 20 /min (adult pattern)'],
],
},
spo2: {
title: 'Pulse oximetry (SpO₂) — at room air',
icon: 'fa-heart-pulse',
rows: [
['≥ 95%', 'Normal'],
['9294%', 'Mild hypoxemia — investigate cause'],
['< 92%', 'Moderate hypoxemia — supplemental O₂'],
['< 88%', 'Severe — urgent intervention; target ≥ 90% acutely'],
],
},
silverman: {
title: 'SilvermanAndersen retraction score (neonatal, 010)',
icon: 'fa-baby',
rows: [
['0', 'No respiratory distress'],
['13', 'Mild — close observation'],
['46', 'Moderate distress — consider CPAP / support'],
['710', 'Severe — imminent respiratory failure, intubate'],
],
},
westley: {
title: 'Westley croup severity score',
icon: 'fa-stethoscope',
rows: [
['≤ 2', 'Mild — home management, cool mist, oral dexamethasone'],
['35', 'Moderate — nebulised epinephrine + dexamethasone'],
['611', 'Severe — admit, continuous monitoring'],
['≥ 12', 'Impending respiratory failure — ICU / airway management'],
],
},
murmurGrade: {
title: 'Heart-murmur grading (Levine 16)',
icon: 'fa-wave-square',
rows: [
['1/6', 'Very faint — heard only with concentration'],
['2/6', 'Soft but readily heard'],
['3/6', 'Moderately loud, no thrill'],
['4/6', 'Loud WITH a palpable thrill'],
['5/6', 'Very loud; audible with stethoscope just off the chest'],
['6/6', 'Audible without the stethoscope touching the chest'],
],
},
pulseAmp: {
title: 'Pulse amplitude grade (04)',
icon: 'fa-heart-pulse',
rows: [
['0', 'Absent'],
['1+', 'Diminished, thready'],
['2+', 'Normal'],
['3+', 'Bounding'],
['4+', 'Bounding with visible pulsation (e.g., aortic regurgitation)'],
],
},
capRefill: {
title: 'Capillary refill time',
icon: 'fa-hand',
rows: [
['< 2 sec', 'Normal'],
['23 sec', 'Borderline — consider hydration / perfusion'],
['≥ 3 sec', 'Delayed — dehydration, shock, low cardiac output'],
],
},
};
export const SYSTEM_SCALES: Record<string, string[]> = {
msk: ['atr', 'beighton'],
neuro: ['mrc', 'dtr', 'plantar'],
resp: ['rr', 'spo2', 'silverman', 'westley'],
cv: ['murmurGrade', 'pulseAmp', 'capRefill'],
};
// APTM — the 5 classic cardiac auscultation points
export interface AptmEntry {
letter: string;
color: string;
title: string;
location: string;
listen: string;
innocent?: string;
}
export const APTM_LEGEND: AptmEntry[] = [
{ letter: 'A', color: '#dc2626', title: 'Aortic area', location: '2nd ICS, right sternal border', listen: 'S2 (aortic component), aortic stenosis, aortic regurgitation' },
{ letter: 'P', color: '#2563eb', title: 'Pulmonic area', location: '2nd ICS, left sternal border', listen: 'S2 (pulmonic component), pulmonic stenosis, PDA, physiologic split of S2',
innocent: 'Pulmonary flow murmur (children, adolescents) — upper left sternal border' },
{ letter: 'E', color: '#059669', title: 'Erb\'s point', location: '3rd ICS, left sternal border', listen: 'Aortic regurgitation (best here), transitional zone murmurs',
innocent: 'Still\'s murmur classically radiates to Erb\'s / LLSB' },
{ letter: 'T', color: '#d97706', title: 'Tricuspid area', location: '4th5th ICS, lower left sternal border', listen: 'Tricuspid regurgitation, VSD, S3/S4, holosystolic murmurs',
innocent: 'Still\'s murmur — vibratory, musical, age 37 y (loudest between LLSB and apex)' },
{ letter: 'M', color: '#7c3aed', title: 'Mitral area (apex)', location: '5th ICS, mid-clavicular line', listen: 'S1, mitral regurgitation, mitral stenosis (with bell, left-lateral decubitus)' },
];
// Innocent (benign) childhood murmurs
export interface InnocentMurmur {
name: string;
age: string;
location: string;
character: string;
confirm: string;
}
export const INNOCENT_MURMURS: InnocentMurmur[] = [
{ name: 'Still\'s (vibratory) murmur',
age: '37 y (most common in children)',
location: 'LLSB, radiating to apex',
character: 'Low-frequency vibratory / musical systolic, grade 23/6, mid-systolic, "twanging-string" quality',
confirm: 'Louder supine, softer or disappears on standing or Valsalva. No radiation to neck/back. Normal S2.' },
{ name: 'Pulmonary flow murmur',
age: 'School-age and adolescents, thin chest',
location: 'Upper left sternal border (2nd3rd ICS)',
character: 'Soft blowing early systolic ejection, grade 12/6, higher-pitched',
confirm: 'No ejection click. Physiologic split of S2. Louder supine, softer on standing. No radiation.' },
{ name: 'Venous hum',
age: 'Ages 38, disappears by adolescence',
location: 'Supraclavicular or infraclavicular area, usually right',
character: 'Soft continuous hum, louder in diastole. Only innocent continuous murmur.',
confirm: 'Disappears when supine OR when jugular vein is gently compressed (key maneuver). Turning head to opposite side also alters it.' },
{ name: 'Carotid bruit / supraclavicular bruit',
age: 'Children and adolescents',
location: 'Supraclavicular fossa, right > left; may radiate to carotid',
character: 'Brief early systolic, grade 23/6, higher-pitched than Still\'s',
confirm: 'Softer or disappears with hyperextension of the shoulders. Normal cardiac exam otherwise. No radiation below the clavicles.' },
{ name: 'Peripheral pulmonary stenosis (PPS, neonatal)',
age: 'Newborns and infants < 612 months',
location: 'Upper LSB, radiates to BOTH axillae and the back',
character: 'Soft systolic ejection murmur, grade 12/6',
confirm: 'Typical age + radiation to back/axillae. Resolves by age 1 as branch pulmonary arteries grow. Persistence or louder grade warrants echo.' },
];
// Respiratory sounds library — real recordings served from /public/audio/respiratory/
export interface SoundEntry {
key: string;
src: string;
title: string;
where: string;
rate?: string;
features: string;
clinical: string;
}
export const RESP_SOUNDS: SoundEntry[] = [
{ key: 'normal', src: '/audio/respiratory/normal-vesicular.ogg', title: 'Normal vesicular breath sounds',
where: 'Peripheral lung fields',
features: 'Soft, rustling. Inspiration louder and longer than expiration.',
clinical: 'Baseline — deviation elsewhere is what you listen for.' },
{ key: 'wheeze', src: '/audio/respiratory/wheeze.ogg', title: 'Wheeze',
where: 'Diffuse in asthma; localised in foreign body',
features: 'Continuous, high-pitched, musical. Usually expiratory; biphasic if severe.',
clinical: 'Lower-airway narrowing — asthma, bronchiolitis, foreign body, bronchomalacia. Silent chest in severe asthma is an ominous sign.' },
{ key: 'stridor', src: '/audio/respiratory/stridor.ogg', title: 'Stridor',
where: 'Louder over neck than chest — upper airway',
features: 'Continuous, high-pitched, harsh. Classically inspiratory (extrathoracic obstruction); biphasic if fixed.',
clinical: 'Croup, epiglottitis, foreign body, laryngomalacia (infant). Distinguish from wheeze by auscultating the neck — stridor is loudest there.' },
{ key: 'finecrackles', src: '/audio/respiratory/crackles-fine.ogg', title: 'Fine (end-inspiratory) crackles',
where: 'Bibasilar in pulmonary edema/fibrosis; focal in pneumonia',
features: 'Discontinuous, brief, high-pitched. "Velcro" quality. Late inspiratory, do NOT clear with cough.',
clinical: 'Alveolar opening — pulmonary fibrosis, pulmonary edema, early pneumonia, atelectasis.' },
{ key: 'coarsecrackles', src: '/audio/respiratory/crackles-coarse.ogg', title: 'Coarse crackles',
where: 'Lower lobes; either side',
features: 'Discontinuous, longer and louder than fine crackles. Lower-pitched. Can be early or late inspiratory; often clear partly with cough.',
clinical: 'Secretions in larger airways — bronchitis, later pneumonia, bronchiectasis, aspiration.' },
{ key: 'rhonchi', src: '/audio/respiratory/rhonchi.ogg', title: 'Rhonchi',
where: 'Central or anywhere with airway secretions',
features: 'Continuous, low-pitched, snore-like. Typically expiratory. Clear or change with cough.',
clinical: 'Large-airway secretions — bronchitis, pneumonia with large-airway involvement, cystic fibrosis, bronchiectasis.' },
{ key: 'pleuralrub', src: '/audio/respiratory/pleural-rub.ogg', title: 'Pleural friction rub',
where: 'Focal, often lateral or posterior lower chest',
features: 'Grating, creaky — "leather on leather". Biphasic (heard in inspiration and expiration). Does NOT clear with cough.',
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' },
];
// Cardiac sounds library — real recordings from Wikimedia Commons
export const CARDIAC_SOUNDS: SoundEntry[] = [
{ key: 'normal', src: '/audio/cardiac/normal.ogg', title: 'Normal heart sounds (S1, S2)',
where: 'All four classic auscultation points', rate: '~61 bpm reference',
features: '"lub-dub": S1 (closure of mitral + tricuspid) louder at apex; S2 (closure of aortic + pulmonic) louder at base. Physiologic S2 split on inspiration.',
clinical: 'Reference for rhythm, rate, and the normal S1S2 interval. Listen for what\'s changed — not just what\'s added.' },
{ key: 'infant-normal', src: '/audio/cardiac/infant-normal.ogg', title: 'Infant normal heart sounds',
where: 'Infant chest — rate will be higher than adult', rate: 'Pediatric reference (120160 bpm range)',
features: 'Same S1S2 pattern, faster rate. Short diastole makes murmurs easier to miss — careful auscultation needed.',
clinical: 'Reference for neonatal/infant rhythm. Any murmur in the first 72 h should prompt pre/postductal sat screening.' },
{ key: 'vsd', src: '/audio/cardiac/vsd.wav', title: 'Ventricular septal defect (VSD)',
where: 'Lower left sternal border (4th ICS)',
features: 'Harsh, blowing, holosystolic (pansystolic) murmur — plateau shape through all of systole. Often accompanied by a thrill if large.',
clinical: 'Most common congenital heart defect. Small VSD: loud murmur, usually asymptomatic, may close spontaneously. Large VSD: softer murmur (less pressure gradient) but signs of heart failure, pulmonary hypertension.' },
{ key: 'mvp', src: '/audio/cardiac/mitral-prolapse.wav', title: 'Mitral valve prolapse (MVP) — click + late systolic murmur',
where: 'Apex (5th ICS, mid-clavicular line)',
features: 'Mid-systolic click followed by a late-systolic crescendo murmur. Timing of click changes with maneuvers: earlier with standing or Valsalva, later with squatting.',
clinical: 'Often benign, especially in thin young women. Features suggesting need for echo: thickened/redundant leaflets, associated MR, symptoms (palpitations, chest pain), arrhythmias.' },
{ key: 'stills', src: '/audio/cardiac/stills-murmur.ogg', title: 'Still\'s murmur (innocent)',
where: 'LLSB, radiating to apex', rate: 'Classic age 37 y (this recording is a toddler)',
features: 'Low-frequency vibratory / musical systolic, grade 23/6, mid-systolic, "twanging-string" quality.',
clinical: 'The most common innocent murmur of childhood. Louder supine, softer or disappears on standing or Valsalva. Normal S2. No radiation to neck or back. No workup needed when classic.' },
{ key: 'functional', src: '/audio/cardiac/functional-murmur.wav', title: 'Functional (innocent) murmur — adult female',
where: 'Left sternal border, soft systolic',
features: 'Soft systolic murmur in a structurally normal heart — often from increased cardiac output, thin chest wall, anemia, hyperthyroidism, or pregnancy.',
clinical: 'Benign if it meets the 7 S criteria. Investigate if loud (≥3/6), holosystolic, diastolic, radiating, or with thrill / symptoms.' },
];

View file

@ -0,0 +1,173 @@
// ============================================================
// PHYSICAL EXAM GUIDE — minimum-viable React port.
//
// What lands in this commit:
// • Scales reference — MRC, DTR, Plantar, Beighton, ATR, RR by age,
// SpO2, Silverman, Westley, murmur Levine grade, pulse amp, cap refill
// • APTM auscultation-points legend (5 classic cardiac points)
// • Innocent murmurs of childhood reference
// • Respiratory sounds library with audio playback
// • Cardiac sounds library with audio playback
//
// What is intentionally NOT in this commit:
// • PE_DATA — the ~1000-line age-group × system × component × step
// hierarchy. The migration checkpoint memory flags this as the
// class of data where an LLM silently drops entries; it belongs in
// its own session with per-entry counts verified against the
// vanilla source. The exam-step checklist + Generate-Exam-Report
// flow (POST /api/generate-pe-narrative) continues to live in the
// vanilla app at / until that session lands.
// • APTM SVG diagram — big inline SVG; the letter legend is preserved,
// the pictorial chest outline can follow without clinical risk.
// ============================================================
import { useRef } from 'react';
import {
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 btnPrimary = 'rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-medium disabled:opacity-50';
function ScaleCard({ id, scale }: { id: string; scale: ScaleDef }) {
return (
<section className={card} data-testid={'scale-' + id}>
<h3 className="text-base font-semibold">{scale.title}</h3>
<table className="w-full text-sm">
<tbody>
{scale.rows.map(([label, desc], i) => (
<tr key={i} className="border-b border-border last:border-0">
<td className="py-1.5 pr-3 font-mono font-semibold whitespace-nowrap">{label}</td>
<td className="py-1.5 text-muted-foreground">{desc}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function SoundCard({ entry }: { entry: SoundEntry }) {
const ref = useRef<HTMLAudioElement>(null);
return (
<section className={card} data-testid={'sound-' + entry.key}>
<h4 className="text-sm font-semibold">{entry.title}</h4>
<audio ref={ref} controls preload="none" className="w-full">
<source src={entry.src} />
Your browser does not support HTML5 audio.
</audio>
<div className="text-xs space-y-1">
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Where:</span> {entry.where}</div>
{entry.rate && (
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Rate:</span> {entry.rate}</div>
)}
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Features:</span> {entry.features}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide">Clinical:</span> {entry.clinical}</div>
</div>
</section>
);
}
export default function PeGuide() {
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
<header>
<h1 className="text-2xl font-semibold">Physical Exam Guide</h1>
<p className="text-sm text-muted-foreground">
Reference scales, the five cardiac auscultation points, innocent childhood murmurs, and sound libraries.
</p>
</header>
<section className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950/30 p-4 text-sm space-y-2">
<div className="font-semibold text-amber-900 dark:text-amber-100">Exam-step checklist lives in the legacy viewer</div>
<p className="text-amber-900 dark:text-amber-100">
The full age-group × system checklist and the Generate Exam Report flow still run in the legacy app. They port in a
dedicated session so the clinical content can be verified entry-for-entry against the vanilla source.
</p>
<a
href="/#peGuide"
className={btnPrimary + ' inline-block'}
>
Open checklist in legacy viewer
</a>
</section>
{/* Scales */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Grading scales &amp; reference ranges</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Object.entries(SCALES).map(([id, scale]) => (
<ScaleCard key={id} id={id} scale={scale} />
))}
</div>
</section>
{/* APTM legend */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Cardiac auscultation APTM (the five points)</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{APTM_LEGEND.map((a) => (
<div key={a.letter} className={card} data-testid={'aptm-' + a.letter.toLowerCase()}>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white"
style={{ background: a.color }}
>
{a.letter}
</div>
<h3 className="text-base font-semibold">{a.title}</h3>
</div>
<div className="text-sm text-muted-foreground"><strong>Location:</strong> {a.location}</div>
<div className="text-sm text-muted-foreground"><strong>Listen for:</strong> {a.listen}</div>
{a.innocent && (
<div className="text-xs text-green-700 dark:text-green-300 bg-green-50 dark:bg-green-950/30 rounded px-2 py-1">
<strong>Innocent:</strong> {a.innocent}
</div>
)}
</div>
))}
</div>
</section>
{/* Innocent murmurs */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Innocent murmurs of childhood</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{INNOCENT_MURMURS.map((m) => (
<div key={m.name} className={card}>
<h3 className="text-base font-semibold">{m.name}</h3>
<div className="text-sm space-y-1">
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Age:</span> {m.age}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Location:</span> {m.location}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Character:</span> {m.character}</div>
<div><span className="font-semibold text-muted-foreground uppercase tracking-wide text-xs">Confirm benign:</span> {m.confirm}</div>
</div>
</div>
))}
</div>
</section>
{/* Respiratory sounds */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Respiratory sounds library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{RESP_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
{/* Cardiac sounds */}
<section className="space-y-3">
<h2 className="text-lg font-semibold">Cardiac sounds library</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{CARDIAC_SOUNDS.map((s) => <SoundCard key={s.key} entry={s} />)}
</div>
</section>
</div>
);
}

View file

@ -0,0 +1,48 @@
// ============================================================
// PE GUIDE (React port) — smoke tests for the reference page.
//
// Covers the subset that landed in the first commit: scales,
// APTM legend, innocent murmurs, respiratory + cardiac sound
// libraries. The full PE_DATA exam-step checklist is still in
// the legacy viewer and linked to from the amber banner.
// ============================================================
const { test, expect, E2E_BASE } = require('../fixtures');
async function openReactPeGuide(page) {
await page.goto(E2E_BASE + '/app/peguide');
await page.waitForSelector('[data-testid^="scale-"]', { timeout: 15000 });
}
test.describe('React PE Guide — reference content renders', () => {
test('all 12 scales render (MRC, DTR, Plantar, Beighton, ATR, RR, SpO2, Silverman, Westley, murmur Levine, pulse amp, cap refill)', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
const count = await page.locator('[data-testid^="scale-"]').count();
expect(count).toBe(12);
});
test('APTM legend shows all 5 points (A, P, E, T, M)', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
for (const letter of ['a', 'p', 'e', 't', 'm']) {
await expect(page.locator('[data-testid="aptm-' + letter + '"]')).toBeVisible();
}
});
test('sound libraries: 7 respiratory + 6 cardiac entries', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
const respiratoryKeys = ['normal', 'wheeze', 'stridor', 'finecrackles', 'coarsecrackles', 'rhonchi', 'pleuralrub'];
const cardiacKeys = ['normal', 'infant-normal', 'vsd', 'mvp', 'stills', 'functional'];
for (const k of respiratoryKeys) {
expect(await page.locator('[data-testid="sound-' + k + '"]').count()).toBeGreaterThan(0);
}
for (const k of cardiacKeys) {
expect(await page.locator('[data-testid="sound-' + k + '"]').count()).toBeGreaterThan(0);
}
});
test('legacy-viewer link for exam-step checklist is present', async ({ authedPage: _, page }) => {
await openReactPeGuide(page);
await expect(page.getByText('Open checklist in legacy viewer')).toBeVisible();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/app/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script type="module" crossorigin src="/app/assets/index-DSAxM8lT.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-BRcV9crV.css">
<script type="module" crossorigin src="/app/assets/index-qWcH2fjq.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DZzmyejq.css">
</head>
<body>
<div id="root"></div>