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:
parent
019622f5ec
commit
efd3e32574
9 changed files with 229 additions and 10 deletions
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
88
client/src/pages/Catchup.tsx
Normal file
88
client/src/pages/Catchup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
client/src/pages/VaxSchedule.tsx
Normal file
91
client/src/pages/VaxSchedule.tsx
Normal 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 (0–18 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 & 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
2
public/app/assets/index-l9SDIDLh.css
Normal file
2
public/app/assets/index-l9SDIDLh.css
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue