feat(wellvisit): port the 4 sub-tabs vanilla had — By Visit / Milestones / SSHADESS / Note

Earlier WellVisit was a single-pane skeleton — the comment in that
file even admitted "Milestones and SSHADESS sub-tabs land in a
follow-up". This commit lands them, faithful to public/components/
wellvisit.html and the three modules behind it (wellVisit.js,
milestones.js, shadess.js, all @be14578).

WellVisit.tsx is now an 83-line shell that lazy-loads four panels:

  client/src/pages/wellvisit/
    ByVisitAge.tsx  — per-visit AAP Bright Futures recommendations
                       (billing codes, measurements, vaccines,
                       sensory/dev/proc/oral screens, growth +
                       feeding, expected reflexes, BMI table,
                       notes). Status buttons mirror vanilla
                       (Given/Refused/Deferred/Already-Done for
                       vaccines; Done/Refused/N-A for screens).
                       Statuses persist to localStorage under
                       ped_visit_statuses (same key as vanilla).
    Milestones.tsx  — checklist per age group, three-state toggle
                       (✓/✗/blank=not assessed), All-Yes/Clear,
                       Generate → /api/generate-milestone-narrative,
                       3-sentence summary → /api/generate-milestone-
                       summary, Copy-to-Note bridge.
    Shadess.tsx     — 8 SSHADESS domains verbatim from shadess.js
                       (Strengths, School, Home, Activities, Drugs,
                       Emotions/Eating, Sexuality, Safety) with
                       concern_if auto-flag, skip toggle, manual
                       concern toggle, optional Listen-In recorder,
                       Generate → /api/well-visit/shadess.
    VisitNote.tsx   — pre-existing note generator + carry-over
                       pickup from sessionStorage so the user can
                       flow Milestones → SSHADESS → Note without
                       retyping. Now also includes the recorder.

Server: GET /api/schedule-data now also returns wellVisitCodes,
growthReference, reflexesReference, and bmiClassification so the
React side has everything it needs without a second round trip.

Tests:
  shared/clinical/visit-status.ts (+ .test.ts) — pure helpers
  for the visitId → growth/reflex key mapping (the vanilla
  tables collapse 6y/7y/8y/9y/10y onto one window, etc.) and
  the reflex-status color rules. Lives in shared/ so the root
  vitest config picks it up; ByVisitAge.tsx imports from there.
This commit is contained in:
Daniel 2026-04-24 04:32:54 +02:00
parent 8d815ba09e
commit 83f3ac70d5
8 changed files with 1648 additions and 157 deletions

View file

@ -1,176 +1,83 @@
// ============================================================
// WELL VISIT — /api/well-visit/note (minimum-viable single-pane
// port; the vanilla tab has 4 sub-panes for byvisit / milestones /
// SSHADESS / note — each becomes its own sub-route or tab in a
// follow-up commit).
// WELL VISIT — sub-tab shell. Mirrors public/components/wellvisit.html:
// • By Visit Age — AAP Bright Futures recs per visit
// • Milestones — developmental checklist → AI narrative
// • SSHADESS — psychosocial screening (12+ only)
// • Visit Note — final preventive-care note generator
//
// SSHADESS pill is hidden for under-12 visits to match vanilla.
// Each panel lazy-loads so the initial WellVisit bundle stays small.
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import { Suspense, lazy, useState } from 'react';
const TYPE = 'wellvisit' as const;
const ByVisitAge = lazy(() => import('./wellvisit/ByVisitAge'));
const Milestones = lazy(() => import('./wellvisit/Milestones'));
const Shadess = lazy(() => import('./wellvisit/Shadess'));
const VisitNote = lazy(() => import('./wellvisit/VisitNote'));
type SubTab = 'byvisit' | 'milestones' | 'shadess' | 'note';
const TABS: { id: SubTab; icon: string; label: string }[] = [
{ id: 'byvisit', icon: '👶', label: 'By Visit Age' },
{ id: 'milestones', icon: '🍼', label: 'Milestones' },
{ id: 'shadess', icon: '🧠', label: 'SSHADESS (12+)' },
{ id: 'note', icon: '📄', label: 'Visit Note' },
];
const STORAGE_KEY = 'ped_wellvisit_subtab';
function loadSubTab(): SubTab {
try {
const v = localStorage.getItem(STORAGE_KEY);
if (v === 'byvisit' || v === 'milestones' || v === 'shadess' || v === 'note') return v;
} catch { /* ignore */ }
return 'byvisit';
}
export default function WellVisit() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [visitAge, setVisitAge] = useState('');
const [vitals, setVitals] = useState('');
const [measurements, setMeasurements] = useState('');
const [parentConcerns, setParentConcerns] = useState('');
const [transcript, setTranscript] = useState('');
const [screenings, setScreenings] = useState('');
const [vaccines, setVaccines] = useState('');
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
const [result, setResult] = useState<string | null>(null);
const [active, setActive] = useState<SubTab>(loadSubTab);
const generate = useMutation<VisitNoteOk, Error, any>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/well-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
generate.mutate({
patientAge, patientGender, visitAge,
vitals, measurements, parentConcerns,
transcript, screenings, vaccines,
noteStyle,
});
function pick(tab: SubTab) {
setActive(tab);
try { localStorage.setItem(STORAGE_KEY, tab); } catch { /* ignore */ }
}
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<div className="max-w-5xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Well Visit</h1>
<h1 className="text-2xl font-semibold">Well Visit / Preventive Care</h1>
<p className="text-sm text-muted-foreground">
Preventive-care note generation. Milestones and SSHADESS sub-tabs land in a follow-up; this first port covers the Visit Note pane.
AAP 2025 Bright Futures periodicity vaccines, screenings, billing codes, milestones, SSHADESS, and the encounter note.
</p>
</header>
<EncounterToolbar
type={TYPE}
label={label} setLabel={setLabel}
transcript={transcript} generatedNote={result || ''}
partialData={{ age: patientAge, gender: patientGender, visitAge, vitals, measurements, parentConcerns, screenings, vaccines, noteStyle }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.visitAge) setVisitAge(pd.visitAge);
if (pd?.vitals) setVitals(pd.vitals);
if (pd?.measurements) setMeasurements(pd.measurements);
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setResult(null);
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setScreenings(''); setVaccines(''); setNoteStyle('full');
}}
/>
<div className="flex flex-wrap gap-2" data-testid="wellvisit-subnav">
{TABS.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pick(t.id)}
className={
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ' +
(active === t.id
? 'bg-primary text-primary-foreground border-primary'
: 'bg-muted hover:bg-muted/80 border-border')
}
data-testid={'wellvisit-pill-' + t.id}
>
<span className="mr-1" aria-hidden>{t.icon}</span>
{t.label}
</button>
))}
</div>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Visit age</span>
<input className={input} placeholder="e.g. 6 months" value={visitAge} onChange={(e) => setVisitAge(e.target.value)} />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Vital signs</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={vitals} onChange={(e) => setVitals(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Measurements / growth</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={measurements} onChange={(e) => setMeasurements(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parent / patient concerns</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={parentConcerns} onChange={(e) => setParentConcerns(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea className={input + ' min-h-[160px] font-mono text-sm'} value={transcript} onChange={(e) => setTranscript(e.target.value)} />
</label>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Screenings completed</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={screenings} onChange={(e) => setScreenings(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Immunizations today</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={vaccines} onChange={(e) => setVaccines(e.target.value)} />
</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')}>
<option value="full">Full encounter note</option>
<option value="short">Brief SOAP</option>
</select>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || (!patientAge.trim() && !visitAge.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Well Visit Note'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={transcript}
exportLabel="well-visit-note"
exportType="well-visit"
/>
</div>
</section>
)}
<Suspense fallback={<div className="text-sm text-muted-foreground">Loading</div>}>
{active === 'byvisit' && <ByVisitAge />}
{active === 'milestones' && <Milestones />}
{active === 'shadess' && <Shadess />}
{active === 'note' && <VisitNote />}
</Suspense>
</div>
);
}

View file

@ -0,0 +1,462 @@
// ============================================================
// BY VISIT AGE — AAP Bright Futures recommendations per visit.
// Faithful port of public/js/wellVisit.js (@be14578) renderVisitPanel.
//
// Reads /api/schedule-data and renders, per visit:
// • Billing codes (ICD-10 + CPT)
// • Measurements (height/weight/HC/BMI/BP)
// • Vaccines due (with Given/Refused/Deferred/Already Done buttons)
// • Screenings (sensory, developmental, procedures, oral)
// • Expected growth + feeding guidance
// • Expected reflexes
// • BMI classification table (AAP 2023, ages 2+)
// • Notes
//
// Visit statuses persist to localStorage under ped_visit_statuses
// (same key as vanilla so cross-app continuity is preserved).
// "Copy to Visit Note" writes a summary to sessionStorage so the
// Visit Note tab can carry it into the encounter note.
// ============================================================
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
import {
mapToGrowthKey,
mapToReflexKey,
reflexStatusColor,
getStatusValue,
type VisitStatusVal,
} from '@shared/clinical/visit-status';
interface VisitAge { id: string; label: string; era: string }
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
type ItemStatus = 'dot' | 'range' | 'arrow' | string;
interface VisitData {
measurements?: Record<string, ItemStatus>;
vaccines?: VaccineDose[];
sensory?: Record<string, ItemStatus>;
developmental?: Record<string, ItemStatus>;
procedures?: Record<string, ItemStatus>;
oralHealth?: Record<string, ItemStatus>;
notes?: string;
}
interface BillingCodes { icd10: string; cpt: string; description: string }
interface GrowthRef {
weight?: string; length?: string; headCirc?: string;
feeding?: string[]; bmiClassification?: boolean;
}
interface ReflexEntry { name: string; status: string; note: string }
interface ReflexRef { intro?: string; reflexes: ReflexEntry[] }
interface BmiCategory { label: string; range: string; action: string; color: string }
interface BmiClassification { categories: BmiCategory[]; notes?: string }
interface ScheduleDataOk {
visitAges: VisitAge[];
periodicity: Record<string, VisitData>;
wellVisitCodes: Record<string, BillingCodes>;
growthReference: Record<string, GrowthRef>;
reflexesReference: Record<string, ReflexRef>;
bmiClassification: BmiClassification;
vaccineFullNames: Record<string, string>;
}
// Status persistence shape — keyed `<visitId>.<itemKey>`. Mirrors the
// vanilla _visitStatuses state stored under localStorage["ped_visit_statuses"].
type VisitStatuses = Record<string, VisitStatusVal>;
const SCREEN_LABELS: Record<string, string> = {
maternalDepression: 'Maternal/Caregiver Depression Screen (Edinburgh/PHQ)',
developmentalScreening: 'Developmental Screening (ASQ / PEDS)',
autismScreening: 'Autism Screening (M-CHAT-R)',
developmentalSurveillance: 'Developmental Surveillance',
behavioralScreening: 'Social-Emotional/Behavioral Screening (ASQ:SE)',
tobaccoAlcoholDrugs: 'Tobacco / Alcohol / Drug Use Screening (CRAFFT/AUDIT)',
depressionSuicideRisk: 'Depression & Suicide Risk Screening (PHQ-A)',
};
const PROC_LABELS: Record<string, string> = {
newbornBlood: 'Newborn Blood Spot Screening (NBS)',
newbornBilirubin: 'Newborn Bilirubin (TcB or TSB)',
criticalCHD: 'Critical CHD Screening (Pulse Ox)',
immunization: 'Immunizations Review & Update',
anemia: 'Anemia Screening (Hgb/Hct)',
lead: 'Lead Exposure Risk / Blood Lead Level',
tuberculosis: 'Tuberculosis / Latent TB Risk Assessment',
dyslipidemia: 'Dyslipidemia Screening (lipid panel)',
sti: 'STI Screening (gonorrhea / chlamydia / syphilis)',
hiv: 'HIV Screening',
hepB: 'Hepatitis B Screening (HBsAg)',
hepC: 'Hepatitis C Screening (anti-HCV)',
suddenCardiacArrest: 'Sudden Cardiac Arrest Risk Assessment',
cervicalDysplasia: 'Cervical Dysplasia Screening (Pap smear)',
};
const MEASURE_LABELS: Record<string, string> = {
lengthHeight: 'Length / Height', weight: 'Weight',
headCircumference: 'Head Circumference', weightForLength: 'Weight-for-Length',
bmi: 'BMI', bloodPressure: 'Blood Pressure',
};
const ORAL_LABELS: Record<string, string> = {
assessment: 'Oral Health Risk Assessment',
fluorideVarnish: 'Fluoride Varnish Application',
fluorideSupplementation: 'Fluoride Supplementation (if water <0.6 ppm)',
};
const SENSORY_LABELS: Record<string, string> = {
vision: 'Vision Screening', hearing: 'Hearing Screening',
};
const ERA_NAMES: Record<string, string> = {
prenatal: 'Prenatal',
infancy: 'Infancy (012 mo)',
earlyChildhood: 'Early Childhood (15 y)',
middleChildhood: 'Middle Childhood (611 y)',
adolescence: 'Adolescence (1121 y)',
};
const STORAGE_KEY = 'ped_visit_statuses';
const VAX_STATUSES = ['Given', 'Refused', 'Deferred', 'Already Done'] as const;
const SCREEN_STATUSES = ['Done', 'Refused', 'Not Due / N/A'] as const;
// localStorage helpers — defensive against quota / blocked storage.
function loadStatuses(): VisitStatuses {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as VisitStatuses) : {};
} catch { return {}; }
}
function saveStatuses(s: VisitStatuses) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)); } catch { /* ignore */ }
}
// ── Sub-components ─────────────────────────────────────────────────
function StatusBar(props: {
statuses: readonly string[];
current: string;
onPick: (next: string) => void;
}) {
return (
<div className="flex flex-wrap gap-1">
{props.statuses.map((s) => (
<button
key={s}
type="button"
onClick={() => props.onPick(props.current === s ? '' : s)}
className={
'text-[10px] uppercase tracking-wider px-2 py-0.5 rounded border ' +
(props.current === s
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border hover:bg-muted')
}
>
{s}
</button>
))}
</div>
);
}
function Section(props: { icon: string; title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="px-4 py-2 bg-muted/40 flex items-center gap-2 text-sm font-semibold">
<span>{props.icon}</span><span>{props.title}</span>
</div>
<div className="p-3 space-y-2">{props.children}</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────
export default function ByVisitAge() {
const { data, isLoading, error } = useQuery<ScheduleDataOk>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleDataOk>('/api/schedule-data'),
});
const [visitId, setVisitId] = useState<string>('newborn');
const [statuses, setStatuses] = useState<VisitStatuses>(() => loadStatuses());
const [msg, setMsg] = useState<string | null>(null);
// Persist on every change.
useEffect(() => { saveStatuses(statuses); }, [statuses]);
// Persist selected visit age to sessionStorage so the Visit Note tab can
// pick it up (matches vanilla `ped_visit_age` key).
useEffect(() => {
if (!data || !visitId) return;
const label = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
try { sessionStorage.setItem('ped_visit_age', label); } catch { /* ignore */ }
}, [data, visitId]);
const groupedAges = useMemo(() => {
if (!data) return {} as Record<string, VisitAge[]>;
const out: Record<string, VisitAge[]> = {};
data.visitAges.forEach((v) => {
if (!out[v.era]) out[v.era] = [];
out[v.era].push(v);
});
return out;
}, [data]);
if (isLoading) return <div className="text-sm text-muted-foreground">Loading schedule</div>;
if (error) return <div className="text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const periodicity = data.periodicity[visitId];
const codes = data.wellVisitCodes[visitId];
const growthKey = mapToGrowthKey(visitId, data.growthReference);
const growth = growthKey ? data.growthReference[growthKey] : null;
const reflexKey = mapToReflexKey(visitId, data.reflexesReference);
const reflex = reflexKey ? data.reflexesReference[reflexKey] : null;
function setStatus(key: string, status: string) {
setStatuses((s) => {
const cur = s[key];
const note = typeof cur === 'object' && cur ? cur.note : '';
return { ...s, [key]: { status, note } };
});
}
function setNote(key: string, note: string) {
setStatuses((s) => {
const cur = s[key];
const status = typeof cur === 'object' && cur ? cur.status : (cur || '');
return { ...s, [key]: { status, note } };
});
}
function clearVisit() {
setStatuses((s) => {
const out = { ...s };
Object.keys(out).forEach((k) => { if (k.indexOf(visitId + '.') === 0) delete out[k]; });
return out;
});
setMsg('Visit statuses cleared');
}
function copyToNote() {
if (!data) return;
const lines: string[] = [];
const visitLabel = data.visitAges.find((v) => v.id === visitId)?.label || visitId;
lines.push('Visit: ' + visitLabel);
Object.keys(statuses).forEach((k) => {
if (k.indexOf(visitId + '.') !== 0) return;
const v = statuses[k];
const status = typeof v === 'object' ? v.status : v;
const note = typeof v === 'object' ? v.note : '';
if (!status) return;
const itemKey = k.substring(visitId.length + 1);
lines.push(' - ' + itemKey + ': ' + status + (note ? ' (' + note + ')' : ''));
});
try {
sessionStorage.setItem('wv-byvisit-statuses', lines.join('\n'));
setMsg('Copied to Visit Note — switch tabs to use it');
} catch { setMsg('Session storage unavailable'); }
}
// Filter helpers for the screening sections.
function buildItems(map: Record<string, ItemStatus> | undefined, labels: Record<string, string>, exclude: string[] = []) {
if (!map) return [] as { key: string; label: string; status: ItemStatus }[];
return Object.keys(map)
.filter((k) => !exclude.includes(k))
.filter((k) => map[k] === 'dot' || map[k] === 'range' || map[k] === 'arrow')
.map((k) => ({ key: k, label: labels[k] || k, status: map[k] }));
}
const measDue = periodicity?.measurements
? Object.keys(periodicity.measurements).filter((k) => periodicity.measurements![k] === 'dot' || periodicity.measurements![k] === 'range')
: [];
const sensoryItems = buildItems(periodicity?.sensory, SENSORY_LABELS);
const devItems = buildItems(periodicity?.developmental, SCREEN_LABELS);
const procItems = buildItems(periodicity?.procedures, PROC_LABELS, ['immunization']);
const oralItems = buildItems(periodicity?.oralHealth, ORAL_LABELS);
return (
<div className="space-y-4">
<div className="flex items-end gap-3 flex-wrap">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Select visit age</span>
<select
className="rounded-md border border-input bg-background px-3 py-2 text-sm min-w-[260px]"
value={visitId}
onChange={(e) => setVisitId(e.target.value)}
data-testid="byvisit-select"
>
{Object.keys(groupedAges).map((era) => (
<optgroup key={era} label={ERA_NAMES[era] || era}>
{groupedAges[era].map((v) => (
<option key={v.id} value={v.id}>{v.label}</option>
))}
</optgroup>
))}
</select>
</label>
<button type="button" onClick={copyToNote} className="rounded-md bg-primary text-primary-foreground px-3 py-2 text-xs font-semibold">
📋 Copy to Visit Note
</button>
<button type="button" onClick={clearVisit} className="rounded-md border border-border bg-background px-3 py-2 text-xs text-destructive">
Clear this visit
</button>
{msg && <span className="text-xs text-muted-foreground">{msg}</span>}
</div>
{!periodicity && <p className="text-sm text-muted-foreground">No data for this visit.</p>}
{periodicity && codes && (
<Section icon="🧾" title="Billing codes">
<div className="flex flex-wrap gap-3 text-sm">
<span><span className="text-xs text-muted-foreground mr-1">ICD-10:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.icd10}</code></span>
<span><span className="text-xs text-muted-foreground mr-1">CPT:</span><code className="bg-muted px-1.5 py-0.5 rounded">{codes.cpt}</code></span>
</div>
<div className="text-xs text-muted-foreground">{codes.description}</div>
</Section>
)}
{measDue.length > 0 && (
<Section icon="📏" title="Measurements">
<div className="flex flex-wrap gap-2">
{measDue.map((k) => (
<span key={k} className="text-xs px-2 py-1 rounded bg-muted">
{MEASURE_LABELS[k] || k}{periodicity!.measurements![k] === 'range' ? ' (range)' : ''}
</span>
))}
</div>
</Section>
)}
{periodicity?.vaccines && periodicity.vaccines.length > 0 && (
<Section icon="💉" title="Vaccines due">
<div className="space-y-2">
{periodicity.vaccines.map((v) => {
const itemKey = 'vax_' + v.vaccine + '_d' + (v.dose || '');
const k = visitId + '.' + itemKey;
const cur = getStatusValue(statuses[k]);
const fullName = data.vaccineFullNames[v.vaccine] || v.vaccine;
return (
<div key={itemKey} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1 min-w-[200px]">
<strong>{fullName}</strong>
{v.dose && <span className="ml-2 text-xs text-muted-foreground">Dose {v.dose}</span>}
{v.notes && <div className="text-xs text-muted-foreground">{v.notes}</div>}
</span>
<StatusBar statuses={VAX_STATUSES} current={cur} onPick={(s) => setStatus(k, s)} />
</div>
);
})}
</div>
</Section>
)}
{sensoryItems.length > 0 && (
<ScreenList title="Sensory screens" icon="👁" items={sensoryItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{devItems.length > 0 && (
<ScreenList title="Developmental / behavioral screens" icon="🧠" items={devItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{procItems.length > 0 && (
<ScreenList title="Labs & procedures" icon="🧪" items={procItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{oralItems.length > 0 && (
<ScreenList title="Oral health" icon="🦷" items={oralItems} visitId={visitId} statuses={statuses} setStatus={setStatus} setNote={setNote} />
)}
{growth && (
<Section icon="📈" title="Expected growth">
<div className="space-y-1 text-sm">
{growth.weight && <div><span className="text-muted-foreground"> Weight: </span>{growth.weight}</div>}
{growth.length && <div><span className="text-muted-foreground">📏 Length/Height: </span>{growth.length}</div>}
{growth.headCirc && <div><span className="text-muted-foreground">🧠 Head circumference: </span>{growth.headCirc}</div>}
</div>
{growth.feeding && growth.feeding.length > 0 && (
<div className="pt-2 border-t border-border mt-2">
<div className="text-xs font-semibold text-muted-foreground mb-1">🍽 Feeding & nutrition</div>
<ul className="list-disc pl-5 text-sm space-y-0.5">
{growth.feeding.map((f, i) => <li key={i}>{f}</li>)}
</ul>
</div>
)}
</Section>
)}
{reflex && reflex.reflexes.length > 0 && (
<Section icon="✋" title="Expected reflexes">
{reflex.intro && <div className="text-xs text-muted-foreground mb-2">{reflex.intro}</div>}
<div className="space-y-2">
{reflex.reflexes.map((r, i) => {
const c = reflexStatusColor(r.status);
return (
<div key={i} className="text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold">{r.name}</span>
<span
className="text-[11px] font-semibold px-2 py-0.5 rounded-full border"
style={{ backgroundColor: c + '1a', color: c, borderColor: c + '66' }}
>{r.status}</span>
</div>
<div className="text-xs text-muted-foreground">{r.note}</div>
</div>
);
})}
</div>
</Section>
)}
{growth?.bmiClassification && data.bmiClassification?.categories && (
<Section icon="⚖" title="BMI / weight classification (AAP 2023)">
<div className="space-y-1">
{data.bmiClassification.categories.map((c, i) => (
<div key={i} className="grid grid-cols-[1fr_auto_2fr] gap-2 text-sm border-l-4 pl-2 py-1" style={{ borderLeftColor: c.color }}>
<strong>{c.label}</strong>
<span className="text-muted-foreground">{c.range}</span>
<span>{c.action}</span>
</div>
))}
</div>
{data.bmiClassification.notes && <div className="text-xs text-muted-foreground pt-1"> {data.bmiClassification.notes}</div>}
</Section>
)}
{periodicity?.notes && (
<div className="rounded-md bg-amber-50 dark:bg-amber-950/30 border border-amber-200 p-3 text-sm">
{periodicity.notes}
</div>
)}
</div>
);
}
function ScreenList(props: {
icon: string;
title: string;
items: { key: string; label: string; status: string }[];
visitId: string;
statuses: VisitStatuses;
setStatus: (k: string, s: string) => void;
setNote: (k: string, n: string) => void;
}) {
return (
<Section icon={props.icon} title={props.title}>
<div className="space-y-2">
{props.items.map((it) => {
const k = props.visitId + '.' + it.key;
const v = props.statuses[k];
const cur = getStatusValue(v);
const note = typeof v === 'object' && v ? v.note : '';
return (
<div key={it.key} className="flex flex-wrap items-center gap-2 text-sm">
<span className="flex-1 min-w-[200px]">{it.label}</span>
<StatusBar statuses={SCREEN_STATUSES} current={cur} onPick={(s) => props.setStatus(k, s)} />
{cur === 'Refused' || cur === 'Done' ? (
<input
type="text"
value={note}
onChange={(e) => props.setNote(k, e.target.value)}
placeholder="note (optional)"
className="rounded-md border border-input bg-background px-2 py-1 text-xs flex-1 min-w-[160px]"
/>
) : null}
</div>
);
})}
</div>
</Section>
);
}

View file

@ -0,0 +1,328 @@
// ============================================================
// MILESTONES — developmental checklist per age group → AI narrative.
// Faithful port of public/js/milestones.js (@be14578).
//
// Flow:
// 1. GET /api/milestones-data → { [ageGroup]: { [domain]: string[] } }
// 2. User picks age group → renders checklist with ✓ / ✗ toggles
// (third state = null = "not assessed, omit from narrative")
// 3. Generate → POST /api/generate-milestone-narrative
// 4. Optional 3-sentence summary → /api/generate-milestone-summary
// 5. "Copy to Note" carries the narrative to the Visit Note tab
// (sessionStorage bridge: wv-milestones-narrative)
// ============================================================
import { useEffect, useMemo, useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import OutputActions from '@/components/OutputActions';
type Status = 'yes' | 'no' | null;
type MilestonesData = Record<string, Record<string, string[]>>;
interface MilestonesDataOk { success: true; milestones: MilestonesData }
interface NarrativeOk {
success: true;
narrative: string;
model: string;
summary?: { achieved: number; notAchieved: number; notAssessed: number };
}
interface SummaryOk { success: true; summary: string; model: string }
interface ItemState {
domain: string;
milestone: string;
status: Status;
}
// Icon + tint per developmental domain (from the vanilla DOMAIN_CONFIG).
const DOMAIN_CONFIG: Record<string, { icon: string; className: string }> = {
'Gross Motor': { icon: '🏃', className: 'border-l-4 border-l-blue-500' },
'Fine Motor': { icon: '✋', className: 'border-l-4 border-l-purple-500' },
'Language': { icon: '💬', className: 'border-l-4 border-l-emerald-500' },
'Social/Emotional':{ icon: '😊', className: 'border-l-4 border-l-amber-500' },
'Cognitive': { icon: '🧠', className: 'border-l-4 border-l-pink-500' },
'Self-Help': { icon: '🧒', className: 'border-l-4 border-l-indigo-500' },
'Feeding': { icon: '🍼', className: 'border-l-4 border-l-cyan-500' },
'Sleep': { icon: '💤', className: 'border-l-4 border-l-slate-500' },
};
const DEFAULT_DOMAIN = { icon: '📋', className: 'border-l-4 border-l-border' };
const AGE_GROUP_ORDER = [
'Newborn / 1 month', '2 months', '4 months', '6 months', '9 months',
'12 months', '15 months', '18 months', '24 months', '30 months',
'36 months', '48 months', '60 months',
'6 years', '7 years', '8 years', '9 years', '10 years', '11 years',
];
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm font-semibold disabled:opacity-50';
export default function Milestones() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [ageGroup, setAgeGroup] = useState('');
const [format, setFormat] = useState<'narrative' | 'list'>('narrative');
const [state, setState] = useState<Record<string, ItemState>>({});
const [narrative, setNarrative] = useState<string | null>(null);
const [summaryStats, setSummaryStats] = useState<NarrativeOk['summary']>(undefined);
const [quickSummary, setQuickSummary] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const { data, isLoading } = useQuery<MilestonesDataOk>({
queryKey: ['milestones-data'],
queryFn: () => api.get<MilestonesDataOk>('/api/milestones-data'),
});
// Keep the age-group dropdown in the canonical order even if server returns
// them alphabetically. Anything not in AGE_GROUP_ORDER appears at the bottom.
const ageGroupKeys = useMemo(() => {
const available = data?.milestones ? Object.keys(data.milestones) : [];
const ordered = AGE_GROUP_ORDER.filter((a) => available.includes(a));
const extras = available.filter((a) => !AGE_GROUP_ORDER.includes(a));
return [...ordered, ...extras];
}, [data]);
// When ageGroup changes, (re)build the checklist state.
useEffect(() => {
if (!ageGroup || !data?.milestones?.[ageGroup]) { setState({}); return; }
const newState: Record<string, ItemState> = {};
const domains = data.milestones[ageGroup];
Object.keys(domains).forEach((domain) => {
domains[domain].forEach((m, idx) => {
newState[domain + '-' + idx] = { domain, milestone: m, status: null };
});
});
setState(newState);
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
}, [ageGroup, data]);
const narrativeMut = useMutation<NarrativeOk, Error, void>({
mutationFn: async () => {
const body = {
milestones: Object.values(state),
ageGroup,
patientAge,
patientGender,
format,
};
return api.post<NarrativeOk>('/api/generate-milestone-narrative', body);
},
onSuccess: (d) => {
setNarrative(d.narrative);
setSummaryStats(d.summary);
setQuickSummary(null);
setMsg({ kind: 'ok', text: 'Generated' });
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
});
const summaryMut = useMutation<SummaryOk, Error, void>({
mutationFn: async () => api.post<SummaryOk>('/api/generate-milestone-summary', {
narrative, ageGroup, patientAge, patientGender,
}),
onSuccess: (d) => { setQuickSummary(d.summary); setMsg({ kind: 'ok', text: 'Summary generated' }); },
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Failed' }),
});
function toggle(id: string, action: 'yes' | 'no') {
setState((s) => {
const cur = s[id];
if (!cur) return s;
const next: Status = cur.status === action ? null : action;
return { ...s, [id]: { ...cur, status: next } };
});
}
function allYes() { setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: 'yes' as Status }]))); }
function allClear() {
setState((s) => Object.fromEntries(Object.entries(s).map(([k, v]) => [k, { ...v, status: null as Status }])));
setNarrative(null); setSummaryStats(undefined); setQuickSummary(null);
}
function generate() {
if (!ageGroup) { setMsg({ kind: 'err', text: 'Select age group' }); return; }
const assessed = Object.values(state).filter((m) => m.status !== null);
if (!assessed.length) { setMsg({ kind: 'err', text: 'Assess at least one milestone' }); return; }
setMsg(null);
narrativeMut.mutate();
}
function copyToNote() {
if (!narrative) return;
try {
sessionStorage.setItem('wv-milestones-narrative', narrative);
setMsg({ kind: 'ok', text: 'Copied to Visit Note — switch to that tab' });
} catch {
setMsg({ kind: 'err', text: 'Session storage unavailable' });
}
}
// Group checklist items by domain for rendering.
const grouped = useMemo(() => {
const out: Record<string, { id: string; text: string; status: Status }[]> = {};
Object.entries(state).forEach(([id, v]) => {
if (!out[v.domain]) out[v.domain] = [];
out[v.domain].push({ id, text: v.milestone, status: v.status });
});
return out;
}, [state]);
const domainCounts = useMemo(() => {
const out: Record<string, { total: number; yes: number; no: number }> = {};
Object.values(state).forEach((v) => {
const c = out[v.domain] || (out[v.domain] = { total: 0, yes: 0, no: 0 });
c.total++;
if (v.status === 'yes') c.yes++;
if (v.status === 'no') c.no++;
});
return out;
}, [state]);
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 9 months" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Age group</span>
<select className={input} value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)} data-testid="ms-age-group">
<option value="">-- Select --</option>
{ageGroupKeys.map((k) => (<option key={k} value={k}>{k}</option>))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Output format</span>
<select className={input} value={format} onChange={(e) => setFormat(e.target.value as 'narrative' | 'list')}>
<option value="narrative">Narrative</option>
<option value="list">Structured list</option>
</select>
</label>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<span><span className="inline-block w-4 h-4 align-middle bg-green-100 text-green-700 text-center rounded mr-1"></span> Achieved</span>
<span><span className="inline-block w-4 h-4 align-middle bg-red-100 text-red-700 text-center rounded mr-1"></span> Not achieved</span>
<span><span className="inline-block w-4 h-4 align-middle bg-muted text-center rounded mr-1"></span> Not assessed (omitted)</span>
</div>
</div>
{isLoading && <div className="text-sm text-muted-foreground">Loading milestones</div>}
{ageGroup && Object.keys(grouped).length === 0 && !isLoading && (
<div className="text-sm text-muted-foreground italic">No data for this age group.</div>
)}
{Object.entries(grouped).map(([domain, items]) => {
const cfg = DOMAIN_CONFIG[domain] || DEFAULT_DOMAIN;
const c = domainCounts[domain];
return (
<div key={domain} className={'rounded-lg bg-card p-0 overflow-hidden ' + cfg.className}>
<div className="px-4 py-2 flex items-center gap-2 bg-muted/40">
<span>{cfg.icon}</span>
<span className="font-semibold">{domain}</span>
<span className="ml-auto text-xs text-muted-foreground" data-testid={'ms-badge-' + domain.replace(/\W/g, '_')}>
{c && c.yes + c.no > 0 ? c.yes + '✓ ' + c.no + '✗ / ' + c.total : c.total + ' items'}
</span>
</div>
<div className="divide-y divide-border">
{items.map((it) => (
<div key={it.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<div className="flex gap-1 shrink-0">
<button
type="button"
onClick={() => toggle(it.id, 'yes')}
className={'w-8 h-8 rounded border text-xs font-bold ' +
(it.status === 'yes' ? 'bg-green-100 text-green-700 border-green-400' : 'bg-background border-border hover:bg-muted')
}
aria-label={'Achieved: ' + it.text}
></button>
<button
type="button"
onClick={() => toggle(it.id, 'no')}
className={'w-8 h-8 rounded border text-xs font-bold ' +
(it.status === 'no' ? 'bg-red-100 text-red-700 border-red-400' : 'bg-background border-border hover:bg-muted')
}
aria-label={'Not achieved: ' + it.text}
></button>
</div>
<span className={
it.status === 'yes' ? 'text-green-700' :
it.status === 'no' ? 'text-red-700 line-through decoration-red-400' : ''
}>{it.text}</span>
</div>
))}
</div>
</div>
);
})}
{Object.keys(grouped).length > 0 && (
<div className="flex flex-wrap gap-2">
<button type="button" onClick={allYes} className={btn}> All yes</button>
<button type="button" onClick={allClear} className={btn}>🧹 Clear all</button>
<button type="button" onClick={generate} disabled={narrativeMut.isPending} className={btnPrimary}>
{narrativeMut.isPending ? 'Generating…' : '✨ Generate narrative'}
</button>
</div>
)}
{msg && (
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
{narrative && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
<h3 className="text-sm font-semibold">Developmental assessment</h3>
<button type="button" onClick={copyToNote} className={btn}>📋 Copy to Note</button>
</header>
{summaryStats && (
<div className="flex gap-4 text-xs px-4 py-2 border-b border-border bg-background text-muted-foreground">
<span> Achieved: {summaryStats.achieved}</span>
<span> Not yet: {summaryStats.notAchieved}</span>
<span> Not assessed: {summaryStats.notAssessed} (omitted)</span>
</div>
)}
<div className="p-4 whitespace-pre-wrap text-sm">{narrative}</div>
<div className="px-4 pb-3">
<OutputActions
text={narrative}
onUpdate={setNarrative}
exportLabel="milestones"
exportType="milestones"
/>
</div>
<div className="px-4 pb-4 pt-2 border-t border-border space-y-2">
<button
type="button"
onClick={() => summaryMut.mutate()}
disabled={summaryMut.isPending}
className={btn}
data-testid="ms-summary-btn"
>
{summaryMut.isPending ? 'Summarizing…' : (quickSummary ? '↻ Regenerate summary' : '📏 3-sentence summary')}
</button>
{quickSummary && (
<div className="rounded-md bg-muted/40 p-3 whitespace-pre-wrap text-sm">
{quickSummary}
</div>
)}
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,363 @@
// ============================================================
// SSHADESS — psychosocial screening (age 12+).
// Faithful port of public/js/shadess.js (@be14578) — same 8 domains,
// same questions, same concern_if flags, same skip toggle.
//
// Flow:
// 1. Clinician fills Yes/No + free-text + comments per domain
// (or hits "Listen in" and dictates the whole thing)
// 2. Generate → POST /api/well-visit/shadess with {patientAge,
// patientGender, domains, dictationText}
// 3. Result auto-fills sessionStorage so the Visit Note tab can
// carry it into the well-visit note (matches the vanilla
// wv-shadess-text auto-fill behaviour).
// ============================================================
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import Recorder from '@/components/Recorder';
import OutputActions from '@/components/OutputActions';
interface YnQ { id: string; text: string; type: 'yn'; concern_if?: boolean }
interface TxtQ { id: string; text: string; type: 'text'; placeholder?: string }
type Q = YnQ | TxtQ;
interface Domain {
key: string;
label: string;
icon: string; // emoji stand-in
color: string; // border tint
intro: string;
questions: Q[];
}
// Verbatim from public/js/shadess.js — 8 domains, exact question wording.
const SHADESS_DOMAINS: Domain[] = [
{ key: 'strengths', label: 'Strengths', icon: '⭐', color: '#f59e0b',
intro: 'Starting with what you do well helps us get to know you better.',
questions: [
{ id: 'str1', text: 'Has something they are proud of or enjoy', type: 'yn' },
{ id: 'str2', text: 'Describes self positively when asked', type: 'yn' },
{ id: 'str3', text: 'Has at least one trusted adult they can talk to', type: 'yn' },
]
},
{ key: 'school', label: 'School', icon: '🎓', color: '#3b82f6',
intro: 'Ask about school performance, attendance, and future plans.',
questions: [
{ id: 'sch1', text: 'Grades are satisfactory / doing their best', type: 'yn' },
{ id: 'sch2', text: 'Likes school or finds something enjoyable about it', type: 'yn' },
{ id: 'sch3', text: 'Regular attendance (no truancy concerns)', type: 'yn' },
{ id: 'sch4', text: 'Has plans or goals for the future', type: 'yn' },
]
},
{ key: 'home', label: 'Home', icon: '🏠', color: '#10b981',
intro: 'Ask about living situation and family relationships.',
questions: [
{ id: 'hom1', text: 'Stable living situation', type: 'yn' },
{ id: 'hom2', text: 'Gets along with people at home', type: 'yn' },
{ id: 'hom3', text: 'Would talk to family member if stressed', type: 'yn' },
{ id: 'hom4', text: 'Has experienced household violence or instability (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'activities', label: 'Activities', icon: '👥', color: '#8b5cf6',
intro: 'Ask about friends, hobbies, and peer relationships.',
questions: [
{ id: 'act1', text: 'Has friends and spends time with them', type: 'yn' },
{ id: 'act2', text: 'Involved in sports, clubs, or hobbies', type: 'yn' },
{ id: 'act3', text: 'Social media/screen use within healthy limits', type: 'yn' },
{ id: 'act4', text: 'Has experienced bullying (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'drugs', label: 'Drugs / Substances', icon: '💊', color: '#ef4444',
intro: 'This is confidential. Ask in private.',
questions: [
{ id: 'drg1', text: 'Has tried cigarettes / vaping / tobacco', type: 'yn', concern_if: true },
{ id: 'drg2', text: 'Has tried alcohol', type: 'yn', concern_if: true },
{ id: 'drg3', text: 'Has tried marijuana or other drugs', type: 'yn', concern_if: true },
{ id: 'drg4', text: 'Friends use substances', type: 'yn' },
{ id: 'drg5', text: 'CRAFFT screen result (if done)', type: 'text', placeholder: 'e.g., Score 0 — low risk' },
]
},
{ key: 'emotions', label: 'Emotions / Eating', icon: '❤️', color: '#ec4899',
intro: 'Screen for depression, anxiety, and disordered eating.',
questions: [
{ id: 'emo1', text: 'Feeling down, sad, or hopeless recently', type: 'yn', concern_if: true },
{ id: 'emo2', text: 'Feeling unusually stressed or anxious', type: 'yn', concern_if: true },
{ id: 'emo3', text: 'Trouble sleeping', type: 'yn' },
{ id: 'emo4', text: 'PHQ-A / depression screen result (if done)', type: 'text', placeholder: 'e.g., PHQ-A score 3 — minimal' },
{ id: 'emo5', text: 'Happy with eating habits and body image', type: 'yn' },
{ id: 'emo6', text: 'Restricting food / purging / using diet pills (concern if YES)', type: 'yn', concern_if: true },
]
},
{ key: 'sexuality', label: 'Sexuality', icon: '🛡️', color: '#f97316',
intro: 'Ask in private. Normalize the questions.',
questions: [
{ id: 'sex1', text: 'Comfortable discussing attraction/identity', type: 'yn' },
{ id: 'sex2', text: 'Sexually active', type: 'yn' },
{ id: 'sex3', text: 'Uses protection consistently if sexually active', type: 'yn' },
{ id: 'sex4', text: 'History of unwanted sexual contact (concern if YES)', type: 'yn', concern_if: true },
{ id: 'sex5', text: 'STI screening indicated/done', type: 'yn' },
]
},
{ key: 'safety', label: 'Safety', icon: '🛡', color: '#6366f1',
intro: 'Ask about violence, weapons, and suicidal ideation.',
questions: [
{ id: 'saf1', text: 'Feels safe at school and home', type: 'yn' },
{ id: 'saf2', text: 'Carries a weapon (concern if YES)', type: 'yn', concern_if: true },
{ id: 'saf3', text: 'Has been in physical fights recently', type: 'yn', concern_if: true },
{ id: 'saf4', text: 'Wears seatbelt; safe driving practices', type: 'yn' },
{ id: 'saf5', text: 'Thoughts of hurting self or suicide (concern if YES — STAT eval)', type: 'yn', concern_if: true },
{ id: 'saf6', text: 'Columbia/ASQ suicide screen result (if done)', type: 'text', placeholder: 'e.g., ASQ: negative' },
]
}
];
interface DomainAnswers {
questions: Record<string, string>; // qid -> 'yes'|'no'|free text
comment: string;
concern: boolean;
skipped: boolean;
}
type AnswersMap = Record<string, DomainAnswers>;
interface ShadessOk { success: true; assessment: string; model: string }
const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm';
const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-2 text-sm font-semibold disabled:opacity-50';
function emptyAnswers(): AnswersMap {
const out: AnswersMap = {};
SHADESS_DOMAINS.forEach((d) => {
out[d.key] = { questions: {}, comment: '', concern: false, skipped: false };
});
return out;
}
export default function Shadess() {
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [answers, setAnswers] = useState<AnswersMap>(emptyAnswers);
const [dictationText, setDictationText] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [result, setResult] = useState<string | null>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const generate = useMutation<ShadessOk, Error, void>({
mutationFn: async () => {
const domains: Record<string, { skipped: boolean; comment: string; concern: boolean; questions: { id: string; text: string; answer: string }[] }> = {};
SHADESS_DOMAINS.forEach((d) => {
const a = answers[d.key];
const qs = d.questions
.map((q) => ({ id: q.id, text: q.text, answer: a.questions[q.id] || '' }))
.filter((q) => q.answer !== '');
domains[d.key] = { skipped: a.skipped, comment: a.comment, concern: a.concern, questions: qs };
});
const hasData = Object.values(domains).some((d) => !d.skipped && (d.questions.length > 0 || d.comment));
if (!hasData && !dictationText.trim()) {
throw new Error('Fill in at least one domain or dictate something');
}
return api.post<ShadessOk>('/api/well-visit/shadess', {
patientAge, patientGender, domains, dictationText: dictationText.trim() || null,
});
},
onSuccess: (d) => {
setResult(d.assessment);
setMsg({ kind: 'ok', text: 'Generated' });
try { sessionStorage.setItem('wv-shadess-assessment', d.assessment); } catch { /* ignore */ }
},
onError: (e) => setMsg({ kind: 'err', text: (e as ApiError).message || 'Generation failed' }),
});
function setQ(domainKey: string, qid: string, value: string, concernIf?: boolean) {
setAnswers((m) => {
const cur = m[domainKey];
const newQuestions = { ...cur.questions, [qid]: value };
// Auto-flag concern when the answer matches the concern_if rule.
let concern = cur.concern;
if (concernIf !== undefined && (value === 'yes') === concernIf) {
concern = true;
}
return { ...m, [domainKey]: { ...cur, questions: newQuestions, concern } };
});
}
function setComment(domainKey: string, value: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], comment: value } }));
}
function toggleSkip(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], skipped: !m[domainKey].skipped } }));
}
function toggleConcern(domainKey: string) {
setAnswers((m) => ({ ...m, [domainKey]: { ...m[domainKey], concern: !m[domainKey].concern } }));
}
function clearAll() {
setAnswers(emptyAnswers());
setDictationText('');
setResult(null);
setMsg(null);
}
return (
<div className="space-y-4">
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Patient age</span>
<input className={input + ' w-32'} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} placeholder="e.g. 14 years" />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase text-muted-foreground">Gender</span>
<select className={input + ' w-40'} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option>
<option>Male</option>
<option>Female</option>
<option>Non-binary/Other</option>
</select>
</label>
<div className="ml-auto text-xs text-muted-foreground">
Recommended age 12 and older. Ask in private.
</div>
</div>
<div>
<span className="text-xs font-semibold uppercase text-muted-foreground">Listen in (optional dictation)</span>
<Recorder
module="shadess"
onTranscript={(text, meta) => {
setDictationText((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setRecError(null);
}}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<textarea
className={input + ' w-full mt-1 min-h-[60px] font-mono text-xs'}
placeholder="Or type the dictation directly. Used as supplementary input alongside the structured answers below."
value={dictationText}
onChange={(e) => setDictationText(e.target.value)}
/>
</div>
</div>
{SHADESS_DOMAINS.map((d) => {
const a = answers[d.key];
return (
<div
key={d.key}
className="rounded-lg border border-border bg-card overflow-hidden"
style={{ borderLeftWidth: 3, borderLeftColor: d.color }}
data-testid={'shadess-domain-' + d.key}
>
<div className="px-4 py-2 flex items-center gap-2 bg-muted/30">
<span style={{ color: d.color }}>{d.icon}</span>
<strong className="text-sm">{d.label}</strong>
<span className="text-xs text-muted-foreground flex-1 ml-2">{d.intro}</span>
{a.concern && (
<span className="text-xs text-amber-700 bg-amber-100 px-2 py-0.5 rounded"> Concern</span>
)}
<button
type="button"
onClick={() => toggleConcern(d.key)}
className="text-xs text-muted-foreground hover:text-foreground"
title="Toggle concern flag"
>🚩</button>
<label className="text-xs text-muted-foreground flex items-center gap-1">
<input type="checkbox" checked={a.skipped} onChange={() => toggleSkip(d.key)} /> Skip
</label>
</div>
<div className={'px-4 py-2 space-y-2 ' + (a.skipped ? 'opacity-30' : '')}>
{d.questions.map((q) => {
if (q.type === 'yn') {
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">
{q.text}
{q.concern_if !== undefined && (
<span className="text-[10px] text-muted-foreground ml-1">
(flag if {q.concern_if ? 'Yes' : 'No'})
</span>
)}
</span>
<select
className={input + ' text-xs py-1 w-24'}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value, q.concern_if)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
>
<option value=""></option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
</div>
);
}
return (
<div key={q.id} className="flex items-center gap-2 text-sm">
<span className="flex-1">{q.text}</span>
<input
type="text"
className={input + ' text-xs py-1 flex-1 max-w-[260px]'}
placeholder={q.placeholder}
value={a.questions[q.id] || ''}
onChange={(e) => setQ(d.key, q.id, e.target.value)}
disabled={a.skipped}
data-testid={'shadess-q-' + q.id}
/>
</div>
);
})}
<div className="flex items-start gap-2 text-sm">
<span className="flex-1 pt-1">Additional notes:</span>
<textarea
rows={2}
className={input + ' text-xs flex-1 min-w-[200px] resize-y'}
placeholder="Free text comments for this domain…"
value={a.comment}
onChange={(e) => setComment(d.key, e.target.value)}
disabled={a.skipped}
/>
</div>
</div>
</div>
);
})}
<div className="flex flex-wrap gap-2">
<button type="button" onClick={() => generate.mutate()} disabled={generate.isPending} className={btnPrimary} data-testid="shadess-generate">
{generate.isPending ? 'Generating…' : '✨ Generate SSHADESS Assessment'}
</button>
<button type="button" onClick={clearAll} className={btn}> New patient</button>
</div>
{msg && (
<div className={'text-sm ' + (msg.kind === 'ok' ? 'text-green-600' : 'text-destructive')}>
{msg.text}
</div>
)}
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h3 className="text-sm font-semibold">SSHADESS Assessment</h3>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={dictationText}
exportLabel="sshadess"
exportType="sshadess"
/>
</div>
<div className="px-4 pb-3 text-xs text-muted-foreground">
Auto-saved to session for the Visit Note tab switch tabs to incorporate.
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,242 @@
// ============================================================
// WELL VISIT — note generator (one of four sub-tabs).
// Picks up SSHADESS + Milestones carry-overs from sessionStorage
// (auto-set by Shadess.tsx and Milestones.tsx) so the user can
// flow byvisit → milestones → shadess → note without retyping.
// ============================================================
import { useEffect, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { VisitNoteOk } from '@/shared/types';
import EncounterToolbar from '@/components/EncounterToolbar';
import OutputActions from '@/components/OutputActions';
import Recorder from '@/components/Recorder';
const TYPE = 'wellvisit' as const;
const input = 'w-full rounded-md border border-input bg-background px-3 py-2 text-sm';
export default function VisitNote() {
const [label, setLabel] = useState('');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
const [visitAge, setVisitAge] = useState('');
const [vitals, setVitals] = useState('');
const [measurements, setMeasurements] = useState('');
const [parentConcerns, setParentConcerns] = useState('');
const [transcript, setTranscript] = useState('');
const [interim, setInterim] = useState('');
const [recError, setRecError] = useState<string | null>(null);
const [shadess, setShadess] = useState('');
const [milestones, setMilestones] = useState('');
const [screenings, setScreenings] = useState('');
const [vaccines, setVaccines] = useState('');
const [byvisit, setByvisit] = useState('');
const [noteStyle, setNoteStyle] = useState<'full' | 'short'>('full');
const [result, setResult] = useState<string | null>(null);
// On mount: pick up SSHADESS / Milestones / By-Visit carry-overs.
useEffect(() => {
try {
const s = sessionStorage.getItem('wv-shadess-assessment');
if (s) setShadess(s);
const m = sessionStorage.getItem('wv-milestones-narrative');
if (m) setMilestones(m);
const b = sessionStorage.getItem('wv-byvisit-statuses');
if (b) setByvisit(b);
const va = sessionStorage.getItem('ped_visit_age');
if (va) setVisitAge((cur) => cur || va);
} catch { /* ignore */ }
}, []);
const generate = useMutation<VisitNoteOk, Error, Record<string, unknown>>({
mutationFn: (body) => api.post<VisitNoteOk>('/api/well-visit/note', body),
onSuccess: (data) => setResult(data.note),
});
function submit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
generate.mutate({
patientAge, patientGender, visitAge,
vitals, measurements, parentConcerns,
transcript: (interim || transcript).trim(),
shadessAssessment: shadess || undefined,
// The byvisit summary is appended to screenings so the AI sees it.
screenings: [screenings, byvisit].filter(Boolean).join('\n\n'),
vaccines,
// Milestones get folded into transcript context as a developmental block.
physicianMemories: milestones ? '[DEVELOPMENTAL ASSESSMENT]\n' + milestones : undefined,
noteStyle,
});
}
const displayedTranscript = interim || transcript;
return (
<div className="space-y-4">
<EncounterToolbar
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 }}
onLoad={(enc) => {
setTranscript(enc.transcript || '');
setInterim('');
setResult(enc.generated_note || null);
try {
const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
if (pd?.age) setPatientAge(pd.age);
if (pd?.gender) setPatientGender(pd.gender);
if (pd?.visitAge) setVisitAge(pd.visitAge);
if (pd?.vitals) setVitals(pd.vitals);
if (pd?.measurements) setMeasurements(pd.measurements);
if (pd?.parentConcerns) setParentConcerns(pd.parentConcerns);
if (pd?.shadess) setShadess(pd.shadess);
if (pd?.milestones) setMilestones(pd.milestones);
if (pd?.byvisit) setByvisit(pd.byvisit);
if (pd?.screenings) setScreenings(pd.screenings);
if (pd?.vaccines) setVaccines(pd.vaccines);
if (pd?.noteStyle) setNoteStyle(pd.noteStyle);
} catch { /* ignore */ }
setLabel(enc.label || '');
}}
onClear={() => {
setTranscript(''); setInterim(''); setResult(null);
setPatientAge(''); setPatientGender(''); setVisitAge('');
setVitals(''); setMeasurements(''); setParentConcerns('');
setShadess(''); setMilestones(''); setByvisit('');
setScreenings(''); setVaccines(''); setNoteStyle('full');
}}
/>
<form onSubmit={submit} className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Age</span>
<input className={input} value={patientAge} onChange={(e) => setPatientAge(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Gender</span>
<select className={input} value={patientGender} onChange={(e) => setPatientGender(e.target.value)}>
<option value="">Select</option><option>Male</option><option>Female</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Visit age</span>
<input className={input} placeholder="e.g. 6 months" value={visitAge} onChange={(e) => setVisitAge(e.target.value)} />
</label>
</div>
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Vital signs</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={vitals} onChange={(e) => setVitals(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Measurements / growth</span>
<textarea className={input + ' min-h-[60px] font-mono text-xs'} value={measurements} onChange={(e) => setMeasurements(e.target.value)} />
</label>
</div>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parent / patient concerns</span>
<textarea className={input + ' min-h-[60px] text-sm'} value={parentConcerns} onChange={(e) => setParentConcerns(e.target.value)} />
</label>
<Recorder
module="wellvisit"
onTranscript={(text, meta) => {
setTranscript((prev) => meta.appended ? (prev ? prev + ' ' + text : text) : text);
setInterim('');
setRecError(null);
}}
onInterim={(t) => setInterim(t ? (transcript ? transcript + ' ' + t : t) : '')}
onError={(m) => setRecError(m)}
/>
{recError && <div className="text-sm text-destructive">{recError}</div>}
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transcript / dictation</span>
<textarea
className={input + ' min-h-[160px] font-mono text-sm'}
value={displayedTranscript}
onChange={(e) => { setTranscript(e.target.value); setInterim(''); }}
placeholder="Click Start recording, or type / paste."
/>
</label>
{(shadess || milestones || byvisit) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-2">
<div className="text-xs font-semibold text-amber-800 dark:text-amber-200">Carry-overs from other tabs (used in note generation)</div>
{milestones && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">Developmental assessment ({milestones.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={milestones} onChange={(e) => setMilestones(e.target.value)} />
</details>
)}
{shadess && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">SSHADESS assessment ({shadess.length} chars)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={shadess} onChange={(e) => setShadess(e.target.value)} />
</details>
)}
{byvisit && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">By-visit-age statuses ({byvisit.split('\n').length} lines)</summary>
<textarea className={input + ' mt-1 min-h-[80px] font-mono text-xs'} value={byvisit} onChange={(e) => setByvisit(e.target.value)} />
</details>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Screenings completed</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={screenings} onChange={(e) => setScreenings(e.target.value)} />
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Immunizations today</span>
<textarea className={input + ' min-h-[60px] text-xs'} value={vaccines} onChange={(e) => setVaccines(e.target.value)} />
</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')}>
<option value="full">Full encounter note</option>
<option value="short">Brief SOAP</option>
</select>
</label>
{generate.error && <div className="text-sm text-destructive">{(generate.error as ApiError).message}</div>}
<button
type="submit"
disabled={generate.isPending || (!patientAge.trim() && !visitAge.trim())}
className="rounded-md bg-primary text-primary-foreground px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{generate.isPending ? 'Generating…' : 'Generate Well Visit Note'}
</button>
</form>
{result && (
<section className="rounded-lg border border-border bg-card">
<header className="px-4 py-2 border-b border-border bg-muted/40">
<h2 className="text-sm font-semibold">Well Visit Note</h2>
</header>
<div className="p-4 whitespace-pre-wrap text-sm">{result}</div>
<div className="px-4 pb-4">
<OutputActions
text={result}
onUpdate={setResult}
sourceContext={displayedTranscript}
exportLabel="well-visit-note"
exportType="well-visit"
/>
</div>
</section>
)}
</div>
);
}

View file

@ -0,0 +1,105 @@
// Tests for shared/clinical/visit-status.ts. The mappings below mirror
// the dictionaries baked into public/js/wellVisit.js (@be14578) — if the
// vanilla schedule changes, both files update in the same commit.
import { describe, expect, it } from 'vitest';
import {
mapToGrowthKey,
mapToReflexKey,
reflexStatusColor,
getStatusValue,
isSshadessVisit,
SSHADESS_VISITS,
} from './visit-status';
const G_KEYS = {
'newborn': {}, '1mo': {}, '2mo': {}, '4mo': {}, '6mo': {}, '9mo': {},
'12mo': {}, '15mo': {}, '18mo': {}, '24mo': {}, '30mo': {},
'3y': {}, '4y': {}, '5y': {},
'6y_to_10y': {}, '11y_to_14y': {}, '15y_to_21y': {},
};
const R_KEYS = {
'newborn': {}, '1mo': {}, '2mo': {}, '4mo': {}, '6mo': {}, '9mo': {},
'12mo': {}, '15mo': {}, '18mo': {}, '24mo': {}, '30mo': {},
'3y_to_5y': {}, '6y_to_10y': {}, '11y_to_14y': {}, '15y_to_21y': {},
};
describe('mapToGrowthKey', () => {
it('returns the visitId itself when it is a direct key', () => {
expect(mapToGrowthKey('newborn', G_KEYS)).toBe('newborn');
expect(mapToGrowthKey('6mo', G_KEYS)).toBe('6mo');
expect(mapToGrowthKey('3y', G_KEYS)).toBe('3y');
});
it('aliases 3-5d to newborn (vanilla parity)', () => {
expect(mapToGrowthKey('3-5d', G_KEYS)).toBe('newborn');
});
it('collapses 6y..10y onto a single growth window', () => {
for (const v of ['6y','7y','8y','9y','10y']) {
expect(mapToGrowthKey(v, G_KEYS)).toBe('6y_to_10y');
}
});
it('collapses adolescence into 11y_to_14y / 15y_to_21y', () => {
for (const v of ['11y','12y','13y','14y']) {
expect(mapToGrowthKey(v, G_KEYS)).toBe('11y_to_14y');
}
for (const v of ['15y','16y','17y','18y','19y','20y','21y']) {
expect(mapToGrowthKey(v, G_KEYS)).toBe('15y_to_21y');
}
});
it('returns null when nothing matches', () => {
expect(mapToGrowthKey('totally-bogus', G_KEYS)).toBeNull();
});
});
describe('mapToReflexKey', () => {
it('groups 3y/4y/5y onto 3y_to_5y (different from growth)', () => {
for (const v of ['3y','4y','5y']) {
expect(mapToReflexKey(v, R_KEYS)).toBe('3y_to_5y');
}
});
it('keeps the same 11y_to_14y / 15y_to_21y windows as growth', () => {
expect(mapToReflexKey('13y', R_KEYS)).toBe('11y_to_14y');
expect(mapToReflexKey('18y', R_KEYS)).toBe('15y_to_21y');
});
});
describe('reflexStatusColor', () => {
it('greens out resolved / integrated reflexes', () => {
expect(reflexStatusColor('Absent / integrated')).toBe('#059669');
expect(reflexStatusColor('Present (lifelong)')).toBe('#059669');
expect(reflexStatusColor('Down-going')).toBe('#059669');
});
it('blues out present-and-expected', () => {
expect(reflexStatusColor('Present')).toBe('#2563eb');
expect(reflexStatusColor('Peak')).toBe('#2563eb');
expect(reflexStatusColor('2+ symmetric')).toBe('#2563eb');
});
it('purples emerging, ambers transitional', () => {
expect(reflexStatusColor('Emerging')).toBe('#7c3aed');
expect(reflexStatusColor('Fading')).toBe('#d97706');
expect(reflexStatusColor('In transition')).toBe('#d97706');
expect(reflexStatusColor('Up-going')).toBe('#d97706');
});
it('falls back to slate for unknowns', () => {
expect(reflexStatusColor('whatever')).toBe('#475569');
expect(reflexStatusColor('')).toBe('#475569');
});
});
describe('getStatusValue', () => {
it('handles undefined, bare strings, and object form', () => {
expect(getStatusValue(undefined)).toBe('');
expect(getStatusValue('Given')).toBe('Given');
expect(getStatusValue({ status: 'Refused', note: '' })).toBe('Refused');
expect(getStatusValue({ status: '', note: 'context' })).toBe('');
});
});
describe('isSshadessVisit', () => {
it('matches all 12+ visits exactly', () => {
for (const v of SSHADESS_VISITS) expect(isSshadessVisit(v)).toBe(true);
});
it('rejects under-12 visits', () => {
for (const v of ['newborn','6mo','11y','5y']) expect(isSshadessVisit(v)).toBe(false);
});
});

View file

@ -0,0 +1,80 @@
// ============================================================
// Visit-status helpers — pure functions used by the WellVisit
// "By Visit Age" pane to map AAP visit IDs onto growth/reflex
// reference keys, color-code reflex chips, and read tri-state
// status values out of the localStorage cache.
//
// Mirrors public/js/wellVisit.js (@be14578) getGrowthDataForVisit /
// getReflexDataForVisit / reflexStatusColor. Lives in shared/ so
// the root vitest config can pick the tests up.
// ============================================================
export type VisitStatusVal = string | { status: string; note: string };
// Map a visit ID (e.g. "7y") to its GROWTH_REFERENCE key (e.g. "6y_to_10y").
// Returns null when no match — caller should hide the growth section.
export function mapToGrowthKey<T>(visitId: string, refs: Record<string, T>): string | null {
if (refs[visitId]) return visitId;
const m: Record<string, string> = {
'newborn': 'newborn', '3-5d': 'newborn',
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
'24mo': '24mo', '30mo': '30mo',
'3y': '3y', '4y': '4y', '5y': '5y',
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y',
};
const k = m[visitId];
return k && refs[k] ? k : null;
}
// REFLEXES_REFERENCE shares 3y..5y, not split by year — see vanilla.
export function mapToReflexKey<T>(visitId: string, refs: Record<string, T>): string | null {
if (refs[visitId]) return visitId;
const m: Record<string, string> = {
'newborn': 'newborn', '3-5d': 'newborn',
'1mo': '1mo', '2mo': '2mo', '4mo': '4mo', '6mo': '6mo', '9mo': '9mo',
'12mo': '12mo', '15mo': '15mo', '18mo': '18mo',
'24mo': '24mo', '30mo': '30mo',
'3y': '3y_to_5y', '4y': '3y_to_5y', '5y': '3y_to_5y',
'6y': '6y_to_10y', '7y': '6y_to_10y', '8y': '6y_to_10y', '9y': '6y_to_10y', '10y': '6y_to_10y',
'11y': '11y_to_14y', '12y': '11y_to_14y', '13y': '11y_to_14y', '14y': '11y_to_14y',
'15y': '15y_to_21y', '16y': '15y_to_21y', '17y': '15y_to_21y', '18y': '15y_to_21y',
'19y': '15y_to_21y', '20y': '15y_to_21y', '21y': '15y_to_21y',
};
const k = m[visitId];
return k && refs[k] ? k : null;
}
// Color a reflex status chip. Greens = expected resolved/lifelong state,
// blues = present and expected, purple = emerging, ambers = transitional,
// slate = unspecified. Mirrors reflexStatusColor() exactly.
export function reflexStatusColor(status: string): string {
const s = (status || '').toLowerCase();
if (s.includes('absent / integrated') || s === 'absent' || s.includes('integrated')) return '#059669';
if (s.includes('present') && s.includes('lifelong')) return '#059669';
if (s.includes('present')) return '#2563eb';
if (s.includes('peak')) return '#2563eb';
if (s.includes('emerging')) return '#7c3aed';
if (s.includes('fading') || s.includes('transition')) return '#d97706';
if (s.includes('down-going')) return '#059669';
if (s.includes('up-going')) return '#d97706';
if (s.includes('2+')) return '#2563eb';
return '#475569';
}
// A visit-status value can be a bare string (legacy) or { status, note }.
export function getStatusValue(s: VisitStatusVal | undefined): string {
if (!s) return '';
return typeof s === 'object' ? s.status : s;
}
// SSHADESS sub-tab visibility — vanilla shows it only for these visits.
export const SSHADESS_VISITS: ReadonlyArray<string> = [
'12y','13y','14y','15y','16y','17y','18y','19y','20y','21y',
];
export function isSshadessVisit(visitId: string): boolean {
return SSHADESS_VISITS.includes(visitId);
}

View file

@ -54,6 +54,10 @@ router.get('/schedule-data', authMiddleware, function (_req: Request, res: Respo
visitAges: scheduleData.VISIT_AGES || [],
periodicity: scheduleData.PERIODICITY || {},
catchUpSchedule: scheduleData.CATCH_UP_SCHEDULE || {},
wellVisitCodes: scheduleData.WELL_VISIT_CODES || {},
growthReference: scheduleData.GROWTH_REFERENCE || {},
reflexesReference: scheduleData.REFLEXES_REFERENCE || {},
bmiClassification: scheduleData.BMI_CLASSIFICATION || {},
vaccineFullNames: VACCINE_FULL_NAMES,
});
});