feat: port Vaccine Schedule + Catch-Up Schedule (real tables, not stubs)

Instead of shipping placeholder pages, added a new backend endpoint
so the React client can render the real AAP/CDC tables without
duplicating the 2000-line pediatricScheduleData module in the client
bundle:

  GET /api/schedule-data  (authed)
    Returns { visitAges, periodicity, catchUpSchedule, vaccineFullNames }
    — everything the vaccine table and catch-up views need from the
    server-side pediatricScheduleData require(). VACCINE_FULL_NAMES
    (which was inlined in public/js/wellVisit.js) now lives in the
    route file so it's single-sourced.

client/src/pages/VaxSchedule.tsx
  useQuery → /api/schedule-data. Renders the full vaccine × visit-age
  grid with sticky header + sticky first column for long scrolling.
  Each filled cell shows the dose number or bullet, with the original
  note text on hover.

client/src/pages/Catchup.tsx
  Card-per-vaccine layout with min-age / min-interval tables and
  catch-up notes. Matches the vanilla layout.

Layout: vaccine + catch-up links now available in the sidebar.

Typecheck green both sides. Vite build 382 kB / 112 kB gzipped.
This commit is contained in:
Daniel 2026-04-23 22:25:36 +02:00
parent 019622f5ec
commit efd3e32574
9 changed files with 229 additions and 10 deletions

View file

@ -10,6 +10,8 @@ import SickVisit from '@/pages/SickVisit';
import HospitalCourse from '@/pages/HospitalCourse';
import ChartReview from '@/pages/ChartReview';
import WellVisit from '@/pages/WellVisit';
import VaxSchedule from '@/pages/VaxSchedule';
import Catchup from '@/pages/Catchup';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
@ -50,6 +52,8 @@ export default function App() {
<Route path="/hospital" element={<HospitalCourse />} />
<Route path="/chart" element={<ChartReview />} />
<Route path="/wellvisit" element={<WellVisit />} />
<Route path="/vaxschedule" element={<VaxSchedule />} />
<Route path="/catchup" element={<Catchup />} />
<Route path="/extensions" element={<Extensions />} />
<Route path="/faq" element={<Faq />} />
{/* catch-all falls back to home while more tabs port over */}

View file

@ -39,8 +39,8 @@ const NAV: NavGroup[] = [
{
label: 'Clinical Tools',
items: [
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: false },
{ to: '/catchup', label: 'Catch-Up Schedule', available: false },
{ to: '/vaxschedule', label: 'Vaccine Schedule', available: true },
{ to: '/catchup', label: 'Catch-Up Schedule', available: true },
{ to: '/peguide', label: 'Physical Exam Guide', available: false },
{ to: '/bedside', label: 'Bedside', available: false },
{ to: '/calculators', label: 'Calculators', available: false },

View file

@ -0,0 +1,88 @@
// ============================================================
// CATCH-UP SCHEDULE — CDC catch-up immunization tables from
// GET /api/schedule-data.
// ============================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface CatchUpSeries { dose: number | string; minimumAge?: string; minimumIntervalToPrev?: string; notes?: string }
interface CatchUpEntry {
minimumAgeForDose1?: string;
series?: CatchUpSeries[];
catchUpNotes?: string | string[];
}
interface ScheduleData {
catchUpSchedule: Record<string, CatchUpEntry>;
vaccineFullNames: Record<string, string>;
}
export default function Catchup() {
const { data, isLoading, error } = useQuery<ScheduleData>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading</div>;
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
return (
<div className="max-w-4xl mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Catch-Up Schedule</h1>
<p className="text-sm text-muted-foreground">
CDC 2025 catch-up immunization schedule minimum ages and intervals per vaccine.
</p>
</header>
{Object.entries(data.catchUpSchedule).map(([key, v]) => {
const fullName = data.vaccineFullNames[key] || key;
const notes = v.catchUpNotes
? (Array.isArray(v.catchUpNotes) ? v.catchUpNotes : [v.catchUpNotes])
: [];
return (
<section key={key} className="rounded-lg border border-border bg-card overflow-hidden">
<header className="px-4 py-2 border-b border-border bg-muted/40 flex items-center justify-between">
<h2 className="text-sm font-semibold">{fullName}</h2>
{v.minimumAgeForDose1 && (
<span className="text-xs text-muted-foreground">
Min age dose 1: <strong>{v.minimumAgeForDose1}</strong>
</span>
)}
</header>
{v.series && v.series.length > 0 && (
<table className="w-full text-xs">
<thead className="bg-muted/20">
<tr>
<th className="text-left px-3 py-2">Dose</th>
<th className="text-left px-3 py-2">Min age</th>
<th className="text-left px-3 py-2">Min interval from prev</th>
<th className="text-left px-3 py-2">Notes</th>
</tr>
</thead>
<tbody>
{v.series.map((s) => (
<tr key={String(s.dose)} className="border-t border-border">
<td className="px-3 py-2 font-semibold">Dose {s.dose}</td>
<td className="px-3 py-2">{s.minimumAge || '—'}</td>
<td className="px-3 py-2">{s.minimumIntervalToPrev || '—'}</td>
<td className="px-3 py-2 text-muted-foreground">{s.notes || ''}</td>
</tr>
))}
</tbody>
</table>
)}
{notes.length > 0 && (
<ul className="list-disc pl-8 py-2 text-xs text-muted-foreground space-y-1">
{notes.map((n, i) => <li key={i}>{n}</li>)}
</ul>
)}
</section>
);
})}
</div>
);
}

View file

@ -0,0 +1,91 @@
// ============================================================
// VACCINE SCHEDULE — full AAP/ACIP table, sourced live from
// GET /api/schedule-data.
// ============================================================
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface VisitAge { id: string; label: string; era: string }
interface VaccineDose { vaccine: string; dose?: number | string; notes?: string }
interface ScheduleData {
visitAges: VisitAge[];
periodicity: Record<string, { vaccines?: VaccineDose[] }>;
vaccineFullNames: Record<string, string>;
}
export default function VaxSchedule() {
const { data, isLoading, error } = useQuery<ScheduleData>({
queryKey: ['schedule-data'],
queryFn: () => api.get<ScheduleData>('/api/schedule-data'),
});
if (isLoading) return <div className="p-6 text-sm text-muted-foreground">Loading schedule</div>;
if (error) return <div className="p-6 text-sm text-destructive">{(error as Error).message}</div>;
if (!data) return null;
const visitsWithVax = data.visitAges.filter((v) => data.periodicity[v.id]?.vaccines?.length);
const vaxKeys: string[] = [];
const seen = new Set<string>();
visitsWithVax.forEach((v) => {
data.periodicity[v.id].vaccines!.forEach((dose) => {
if (!seen.has(dose.vaccine)) { seen.add(dose.vaccine); vaxKeys.push(dose.vaccine); }
});
});
return (
<div className="max-w-full mx-auto p-6 space-y-4">
<header>
<h1 className="text-2xl font-semibold">Vaccine Schedule</h1>
<p className="text-sm text-muted-foreground">
AAP/ACIP 2025 complete immunization schedule (018 years).
</p>
</header>
<div className="rounded-lg border border-border overflow-auto bg-card">
<table className="text-xs">
<thead className="sticky top-0 bg-muted">
<tr>
<th className="text-left font-semibold px-3 py-2 border-b border-border min-w-[180px] sticky left-0 bg-muted">
Vaccine
</th>
{visitsWithVax.map((v) => (
<th key={v.id} className="px-2 py-2 border-b border-border text-center whitespace-nowrap">
{v.label}
</th>
))}
</tr>
</thead>
<tbody>
{vaxKeys.map((key) => (
<tr key={key} className="even:bg-muted/20">
<td className="px-3 py-2 border-b border-border font-medium sticky left-0 bg-card">
{data.vaccineFullNames[key] || key}
</td>
{visitsWithVax.map((v) => {
const vaxList = data.periodicity[v.id].vaccines || [];
const match = vaxList.find((d) => d.vaccine === key);
if (!match) return <td key={v.id} className="border-b border-border" />;
const label = typeof match.dose === 'number' ? '#' + match.dose : (match.dose || '•');
return (
<td
key={v.id}
className="border-b border-border text-center bg-primary/10 font-mono text-[11px]"
title={match.notes || `${key} dose ${match.dose}`}
>
{label}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-muted-foreground">
Hover any filled cell for notes. Sources: AAP/Bright Futures (Feb 2025), CDC Child &amp; Adolescent Immunization Schedule (2025).
</p>
</div>
);
}

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-lT69opLd.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-DyJ0YyCY.css">
<script type="module" crossorigin src="/app/assets/index-DphalJ8r.js"></script>
<link rel="stylesheet" crossorigin href="/app/assets/index-l9SDIDLh.css">
</head>
<body>
<div id="root"></div>

View file

@ -22,6 +22,42 @@ try {
const GROWTH_REFERENCE: Record<string, any> = scheduleData.GROWTH_REFERENCE || {};
const BMI_CLASSIFICATION: any = scheduleData.BMI_CLASSIFICATION || {};
// Vaccine full names — sourced from public/js/wellVisit.js where they were
// inlined. Mirrored here so a single backend response can power the React
// vax schedule page without a second round trip.
const VACCINE_FULL_NAMES: Record<string, string> = {
HepB: 'Hepatitis B (HepB)',
RV: 'Rotavirus (RV)',
DTaP: 'DTaP (Diphtheria, Tetanus, Pertussis)',
Hib: 'Hib (Haemophilus influenzae type b)',
PCV: 'Pneumococcal (PCV)',
IPV: 'Polio (IPV)',
Flu_IIV: 'Influenza (IIV)',
MMR: 'MMR (Measles, Mumps, Rubella)',
VAR: 'Varicella (VAR)',
HepA: 'Hepatitis A (HepA)',
Tdap: 'Tdap (Tetanus, Diphtheria, Pertussis booster)',
HPV: 'HPV (Human Papillomavirus)',
MenACWY: 'Meningococcal ACWY (MenACWY)',
MenB: 'Meningococcal B (MenB)',
RSV_mAb: 'RSV Monoclonal Antibody (Nirsevimab/Beyfortus)',
COVID: 'COVID-19',
Dengue: 'Dengue (DEN4CYD / Dengvaxia)',
Mpox: 'Mpox (JYNNEOS)',
};
// ── GET schedule data — used by the React client to render vaccine
// ── + catch-up tables without duplicating the 2000-line schedule module.
router.get('/schedule-data', authMiddleware, function (_req: Request, res: Response) {
res.json({
success: true,
visitAges: scheduleData.VISIT_AGES || [],
periodicity: scheduleData.PERIODICITY || {},
catchUpSchedule: scheduleData.CATCH_UP_SCHEDULE || {},
vaccineFullNames: VACCINE_FULL_NAMES,
});
});
// Map visit age text to GROWTH_REFERENCE keys
function getGrowthRefForAge(ageStr?: string): any {
if (!ageStr) return null;