Add administered date for vaccinations and WHO growth benchmarks

Vaccinations:
- Allow custom date input when marking vaccine as given
- Show actual administered date alongside scheduled due date

Growth:
- Add WHO child growth standards data (boys/girls)
- Show age-based benchmarks on Growth page
- Display percentile ranges for weight and height
- Show latest measurement compared to standards

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-10 16:17:57 +05:30
parent 967e00c4fa
commit bfc1543b1c
3 changed files with 197 additions and 18 deletions

View file

@ -1,6 +1,14 @@
"use client";
import { useState, useEffect } from "react";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
interface Child {
id: string;
name: string;
birthDate: string;
sex: string;
}
export default function GrowthPage() {
const [childId] = useState("5ad3b16a-1e0d-45ab-bc91-038397d75d0a");
@ -9,6 +17,7 @@ export default function GrowthPage() {
const [showAdd, setShowAdd] = useState(false);
const [weight, setWeight] = useState("");
const [height, setHeight] = useState("");
const [child, setChild] = useState<Child | null>(null);
useEffect(() => {
fetch(`/api/growth?childId=${childId}`)
@ -18,6 +27,15 @@ export default function GrowthPage() {
setLoading(false);
})
.catch(() => setLoading(false));
// Fetch child info
fetch("/api/children?familyId=default")
.then(r => r.json())
.then(data => {
if (data.children?.length > 0) {
setChild(data.children[0]);
}
});
}, [childId]);
const handleAdd = async () => {
@ -32,9 +50,21 @@ export default function GrowthPage() {
}),
});
setShowAdd(false);
window.location.reload();
setWeight("");
setHeight("");
fetch(`/api/growth?childId=${childId}`)
.then(r => r.json())
.then(data => setGrowthData(data.growth || []));
};
// Get latest measurement
const latest = growthData[0];
// Get standard data for comparison
const ageMonths = child ? getAgeInMonthsFromBirth(child.birthDate) : 12;
const standards = child?.sex === "male" ? WHO_BOY_WEIGHT : WHO_GIRL_WEIGHT;
const standard = standards.find(s => s.ageMonths === Math.min(ageMonths, 24)) || standards[standards.length - 1];
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex justify-between items-center">
@ -47,6 +77,51 @@ export default function GrowthPage() {
</button>
</div>
{/* Benchmark Card */}
{child && standard && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex justify-between items-center mb-2">
<div className="font-medium">{child.name}</div>
<div className="text-sm text-gray-500">{ageMonths} months old</div>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-gray-500 mb-1">Weight (50th percentile)</div>
<div className="font-medium text-lg">{standard.weight.p50} kg</div>
<div className="text-xs text-gray-400">Range: {standard.weight.p3}-{standard.weight.p97} kg</div>
</div>
<div>
<div className="text-gray-500 mb-1">Height (50th percentile)</div>
<div className="font-medium text-lg">{standard.height.p50} cm</div>
<div className="text-xs text-gray-400">Range: {standard.height.p3}-{standard.height.p97} cm</div>
</div>
</div>
</div>
)}
{/* Latest Reading */}
{latest && (
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">
<div className="text-sm text-gray-500 mb-2">
Latest: {new Date(latest.measured_at).toLocaleDateString()}
</div>
<div className="flex gap-6">
{latest.weight_kg && (
<div>
<div className="text-gray-500 text-xs">Weight</div>
<div className="text-xl font-bold">{latest.weight_kg} kg</div>
</div>
)}
{latest.height_cm && (
<div>
<div className="text-gray-500 text-xs">Height</div>
<div className="text-xl font-bold">{latest.height_cm} cm</div>
</div>
)}
</div>
</div>
)}
{showAdd && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
<input

View file

@ -43,6 +43,8 @@ export default function MedicalPage() {
const [vaccinations, setVaccinations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<"vaccinations" | "growth">("vaccinations");
const [showAddDate, setShowAddDate] = useState<string | null>(null);
const [givenDate, setGivenDate] = useState("");
const birthDate = "2024-01-15";
@ -58,6 +60,7 @@ export default function MedicalPage() {
const handleMarkGiven = async (vaccineName: string) => {
const dueDate = calculateDueDate(birthDate, IAP_SCHEDULE.find((v) => v.name === vaccineName)?.weeks || 0);
const dateToSave = givenDate || new Date().toISOString().split("T")[0];
await fetch("/api/vaccinations", {
method: "POST",
@ -66,15 +69,18 @@ export default function MedicalPage() {
childId,
vaccineName,
scheduledDate: dueDate,
givenDate: new Date().toISOString().split("T")[0],
givenDate: dateToSave,
status: "given",
}),
});
setVaccinations((prev) => [...prev, { vaccine_name: vaccineName, status: "given" }]);
setVaccinations((prev) => [...prev, { vaccine_name: vaccineName, given_date: dateToSave, status: "given" }]);
setShowAddDate(null);
setGivenDate("");
};
const isGiven = (name: string) => vaccinations.some((v) => v.vaccine_name === name && v.status === "given");
const getGivenDate = (name: string) => vaccinations.find((v) => v.vaccine_name === name && v.status === "given")?.given_date;
const isPending = (name: string) => !isGiven(name);
return (
@ -110,29 +116,55 @@ export default function MedicalPage() {
.sort((a, b) => a.weeks - b.weeks)
.map((vaccine) => {
const given = isGiven(vaccine.name);
const actualDate = getGivenDate(vaccine.name);
const dueDate = calculateDueDate(birthDate, vaccine.weeks);
return (
<div
key={vaccine.name}
className={`flex items-center justify-between p-4 bg-white rounded-xl ${given ? "opacity-60" : ""}`}
className={`p-4 bg-white rounded-xl ${given ? "opacity-60" : ""}`}
>
<div>
<div className="font-medium">{vaccine.name}</div>
<div className="text-sm text-gray-500">
Due: {new Date(dueDate).toLocaleDateString()}
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium">{vaccine.name}</div>
<div className="text-sm text-gray-500">
Due: {new Date(dueDate).toLocaleDateString()}
{actualDate && ` · Given: ${new Date(actualDate).toLocaleDateString()}`}
</div>
</div>
{given ? (
<span className="text-green-500"></span>
) : showAddDate === vaccine.name ? (
<div className="flex gap-2">
<input
type="date"
value={givenDate}
onChange={(e) => setGivenDate(e.target.value)}
className="p-1 text-sm border rounded"
placeholder="Date given"
/>
<button
onClick={() => handleMarkGiven(vaccine.name)}
className="px-3 py-1 bg-rose-400 text-white rounded-lg text-sm"
>
</button>
<button
onClick={() => { setShowAddDate(null); setGivenDate(""); }}
className="px-2 py-1 text-gray-400"
>
</button>
</div>
) : (
<button
onClick={() => setShowAddDate(vaccine.name)}
className="px-4 py-2 bg-rose-400 text-white rounded-lg text-sm"
>
Mark Given
</button>
)}
</div>
{given ? (
<span className="text-green-500"> Given</span>
) : (
<button
onClick={() => handleMarkGiven(vaccine.name)}
className="px-4 py-2 bg-rose-400 text-white rounded-lg text-sm"
>
Mark Given
</button>
)}
</div>
);
})

View file

@ -0,0 +1,72 @@
// WHO Child Growth Standards
// Weight-for-age, Length/Height-for-age, Head circumference-for-age
// Source: WHO Child Growth Standards (2006)
export interface GrowthStandard {
ageMonths: number;
weight: { p50: number; p3: number; p15: number; p85: number; p97: number };
height: { p50: number; p3: number; p15: number; p85: number; p97: number };
}
// Boys weight (kg) by month
export const WHO_BOY_WEIGHT: GrowthStandard[] = [
{ ageMonths: 0, weight: { p50: 3.3, p3: 2.5, p15: 2.9, p85: 3.9, p97: 4.4 }, height: { p50: 49.9, p3: 46.3, p15: 47.9, p85: 51.8, p97: 53.4 } },
{ ageMonths: 1, weight: { p50: 4.5, p3: 3.4, p15: 3.9, p85: 5.2, p97: 5.8 }, height: { p50: 54.7, p3: 50.7, p15: 52.4, p85: 57.0, p97: 58.7 } },
{ ageMonths: 2, weight: { p50: 5.6, p3: 4.3, p15: 4.9, p85: 6.4, p97: 7.1 }, height: { p50: 58.4, p3: 54.1, p15: 55.8, p85: 61.0, p97: 62.7 } },
{ ageMonths: 3, weight: { p50: 6.4, p3: 4.9, p15: 5.6, p85: 7.3, p97: 8.1 }, height: { p50: 61.4, p3: 56.8, p15: 58.6, p85: 64.3, p97: 66.1 } },
{ ageMonths: 4, weight: { p50: 7.0, p3: 5.4, p15: 6.1, p85: 8.0, p97: 8.9 }, height: { p50: 63.9, p3: 59.0, p15: 60.9, p85: 66.8, p97: 68.7 } },
{ ageMonths: 5, weight: { p50: 7.5, p3: 5.8, p15: 6.5, p85: 8.6, p97: 9.5 }, height: { p50: 65.9, p3: 60.8, p15: 62.8, p85: 69.1, p97: 71.0 } },
{ ageMonths: 6, weight: { p50: 7.9, p3: 6.1, p15: 6.9, p85: 9.1, p97: 10.0 }, height: { p50: 67.6, p3: 62.4, p15: 64.5, p85: 70.8, p97: 72.8 } },
{ ageMonths: 7, weight: { p50: 8.3, p3: 6.4, p15: 7.2, p85: 9.5, p97: 10.5 }, height: { p50: 69.2, p3: 63.8, p15: 66.0, p85: 72.4, p97: 74.5 } },
{ ageMonths: 8, weight: { p50: 8.6, p3: 6.6, p15: 7.5, p85: 9.9, p97: 10.9 }, height: { p50: 70.6, p3: 65.1, p15: 67.4, p85: 73.9, p97: 76.0 } },
{ ageMonths: 9, weight: { p50: 8.9, p3: 6.8, p15: 7.7, p85: 10.2, p97: 11.3 }, height: { p50: 72.0, p3: 66.3, p15: 68.7, p85: 75.3, p97: 77.5 } },
{ ageMonths: 10, weight: { p50: 9.2, p3: 7.0, p15: 7.9, p85: 10.5, p97: 11.6 }, height: { p50: 73.3, p3: 67.5, p15: 70.0, p85: 76.7, p97: 78.9 } },
{ ageMonths: 11, weight: { p50: 9.4, p3: 7.1, p15: 8.1, p85: 10.8, p97: 12.0 }, height: { p50: 74.5, p3: 68.7, p15: 71.2, p85: 77.9, p97: 80.2 } },
{ ageMonths: 12, weight: { p50: 9.6, p3: 7.3, p15: 8.3, p85: 11.1, p97: 12.3 }, height: { p50: 75.7, p3: 69.8, p15: 72.4, p85: 79.1, p97: 81.4 } },
{ ageMonths: 15, weight: { p50: 10.3, p3: 7.8, p15: 8.9, p85: 11.9, p97: 13.2 }, height: { p50: 78.8, p3: 72.3, p15: 75.1, p85: 82.4, p97: 84.9 } },
{ ageMonths: 18, weight: { p50: 10.9, p3: 8.3, p15: 9.4, p85: 12.6, p97: 14.0 }, height: { p50: 81.7, p3: 74.7, p15: 77.6, p85: 85.7, p97: 88.3 } },
{ ageMonths: 21, weight: { p50: 11.5, p3: 8.7, p15: 9.9, p85: 13.3, p97: 14.8 }, height: { p50: 84.1, p3: 76.9, p15: 79.9, p85: 88.4, p97: 91.0 } },
{ ageMonths: 24, weight: { p50: 12.2, p3: 9.2, p15: 10.4, p85: 14.0, p97: 15.6 }, height: { p50: 86.4, p3: 78.9, p15: 82.0, p85: 90.7, p97: 93.4 } },
];
// Girls weight (kg) by month
export const WHO_GIRL_WEIGHT: GrowthStandard[] = [
{ ageMonths: 0, weight: { p50: 3.2, p3: 2.4, p15: 2.8, p85: 3.7, p97: 4.2 }, height: { p50: 49.1, p3: 45.4, p15: 47.1, p85: 51.1, p97: 52.7 } },
{ ageMonths: 1, weight: { p50: 4.2, p3: 3.2, p15: 3.6, p85: 4.8, p97: 5.5 }, height: { p50: 53.7, p3: 49.6, p15: 51.3, p85: 56.1, p97: 57.8 } },
{ ageMonths: 2, weight: { p50: 5.1, p3: 3.9, p15: 4.5, p85: 5.9, p97: 6.6 }, height: { p50: 57.1, p3: 52.7, p15: 54.4, p85: 59.7, p97: 61.4 } },
{ ageMonths: 3, weight: { p50: 5.8, p3: 4.5, p15: 5.1, p85: 6.7, p97: 7.5 }, height: { p50: 59.8, p3: 55.1, p15: 57.0, p85: 62.7, p97: 64.5 } },
{ ageMonths: 4, weight: { p50: 6.4, p3: 4.9, p15: 5.6, p85: 7.3, p97: 8.2 }, height: { p50: 62.1, p3: 57.2, p15: 59.1, p85: 65.2, p97: 67.1 } },
{ ageMonths: 5, weight: { p50: 6.9, p3: 5.3, p15: 6.0, p85: 7.9, p97: 8.8 }, height: { p50: 64.0, p3: 58.9, p15: 60.9, p85: 67.2, p97: 69.1 } },
{ ageMonths: 6, weight: { p50: 7.3, p3: 5.6, p15: 6.4, p85: 8.4, p97: 9.3 }, height: { p50: 65.7, p3: 60.4, p15: 62.5, p85: 68.9, p97: 70.9 } },
{ ageMonths: 7, weight: { p50: 7.6, p3: 5.9, p15: 6.7, p85: 8.8, p97: 9.8 }, height: { p50: 67.3, p3: 61.8, p15: 64.0, p85: 70.5, p97: 72.6 } },
{ ageMonths: 8, weight: { p50: 7.9, p3: 6.1, p15: 6.9, p85: 9.1, p97: 10.2 }, height: { p50: 68.7, p3: 63.1, p15: 65.4, p85: 72.0, p97: 74.2 } },
{ ageMonths: 9, weight: { p50: 8.2, p3: 6.3, p15: 7.1, p85: 9.5, p97: 10.5 }, height: { p50: 70.1, p3: 64.4, p15: 66.7, p85: 73.5, p97: 75.7 } },
{ ageMonths: 10, weight: { p50: 8.4, p3: 6.5, p15: 7.4, p85: 9.8, p97: 10.9 }, height: { p50: 71.5, p3: 65.6, p15: 68.0, p85: 75.0, p97: 77.2 } },
{ ageMonths: 11, weight: { p50: 8.7, p3: 6.7, p15: 7.6, p85: 10.1, p97: 11.2 }, height: { p50: 72.8, p3: 66.7, p15: 69.2, p85: 76.4, p97: 78.7 } },
{ ageMonths: 12, weight: { p50: 8.9, p3: 6.8, p15: 7.8, p85: 10.3, p97: 11.5 }, height: { p50: 74.0, p3: 67.7, p15: 70.3, p85: 77.7, p97: 80.1 } },
{ ageMonths: 15, weight: { p50: 9.7, p3: 7.4, p15: 8.4, p85: 11.2, p97: 12.5 }, height: { p50: 77.2, p3: 70.3, p15: 73.0, p85: 81.0, p97: 83.5 } },
{ ageMonths: 18, weight: { p50: 10.3, p3: 7.8, p15: 8.9, p85: 12.0, p97: 13.4 }, height: { p50: 80.2, p3: 72.9, p15: 75.7, p85: 84.3, p97: 86.9 } },
{ ageMonths: 21, weight: { p50: 10.9, p3: 8.3, p15: 9.5, p85: 12.7, p97: 14.2 }, height: { p50: 82.7, p3: 75.3, p15: 78.0, p85: 87.1, p97: 89.8 } },
{ ageMonths: 24, weight: { p50: 11.5, p3: 8.7, p15: 10.0, p85: 13.4, p97: 15.0 }, height: { p50: 85.0, p3: 77.4, p15: 80.2, p85: 89.8, p97: 92.5 } },
];
export function getAgeInMonthsFromBirth(birthDate: string): number {
const birth = new Date(birthDate);
const now = new Date();
return Math.floor((now.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 30));
}
export function getStandard(birthDate: string, sex: string): GrowthStandard {
const ageMonths = getAgeInMonthsFromBirth(birthDate);
const data = sex === "male" ? WHO_BOY_WEIGHT : WHO_GIRL_WEIGHT;
return data.find((d) => d.ageMonths === ageMonths) || data[data.length - 1];
}
export function getPercentile(value: number, p3: number, p15: number, p50: number, p85: number, p97: number): string {
if (value < p3) return "Below 3rd";
if (value < p15) return "3rd-15th";
if (value < p50) return "15th-50th";
if (value < p85) return "50th-85th";
if (value < p97) return "85th-97th";
return "Above 97th";
}