Add growth chart with WHO percentile bands

- Line chart showing child's growth over time
- Toggle between weight/height/head
- WHO 50th percentile reference line
- WHO p3-p97 range zone

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-16 18:31:24 +05:30
parent 0865706a94
commit 387da42286
3 changed files with 149 additions and 1 deletions

View file

@ -11,6 +11,7 @@
"@auth/drizzle-adapter": "^1.11.2", "@auth/drizzle-adapter": "^1.11.2",
"@aws-sdk/client-s3": "^3.1045.0", "@aws-sdk/client-s3": "^3.1045.0",
"@aws-sdk/s3-request-presigner": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.1045.0",
"chart.js": "^4.5.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
@ -21,6 +22,7 @@
"openai": "^6.37.0", "openai": "^6.37.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "19.2.4", "react": "19.2.4",
"react-chartjs-2": "^5.3.1",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },

30
pnpm-lock.yaml generated
View file

@ -17,6 +17,9 @@ importers:
'@aws-sdk/s3-request-presigner': '@aws-sdk/s3-request-presigner':
specifier: ^3.1045.0 specifier: ^3.1045.0
version: 3.1045.0 version: 3.1045.0
chart.js:
specifier: ^4.5.1
version: 4.5.1
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
@ -47,6 +50,9 @@ importers:
react: react:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4 version: 19.2.4
react-chartjs-2:
specifier: ^5.3.1
version: 5.3.1(chart.js@4.5.1)(react@19.2.4)
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
@ -896,6 +902,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@next/env@16.2.6': '@next/env@16.2.6':
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
@ -1289,6 +1298,10 @@ packages:
caniuse-lite@1.0.30001792: caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
engines: {pnpm: '>=8'}
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@ -1632,6 +1645,12 @@ packages:
preact@10.24.3: preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
react-chartjs-2@5.3.1:
resolution: {integrity: sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==}
peerDependencies:
chart.js: ^4.1.1
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom@19.2.4: react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies: peerDependencies:
@ -2555,6 +2574,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@next/env@16.2.6': {} '@next/env@16.2.6': {}
'@next/swc-darwin-arm64@16.2.6': '@next/swc-darwin-arm64@16.2.6':
@ -3011,6 +3032,10 @@ snapshots:
caniuse-lite@1.0.30001792: {} caniuse-lite@1.0.30001792: {}
chart.js@4.5.1:
dependencies:
'@kurkle/color': 0.3.4
client-only@0.0.1: {} client-only@0.0.1: {}
csstype@3.2.3: {} csstype@3.2.3: {}
@ -3279,6 +3304,11 @@ snapshots:
preact@10.24.3: {} preact@10.24.3: {}
react-chartjs-2@5.3.1(chart.js@4.5.1)(react@19.2.4):
dependencies:
chart.js: 4.5.1
react: 19.2.4
react-dom@19.2.4(react@19.2.4): react-dom@19.2.4(react@19.2.4):
dependencies: dependencies:
react: 19.2.4 react: 19.2.4

View file

@ -1,8 +1,31 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { useFamily } from "../FamilyProvider"; import { useFamily } from "../FamilyProvider";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards"; import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";
import { Line } from "react-chartjs-2";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
export default function GrowthPage() { export default function GrowthPage() {
const { childId, child, familyId } = useFamily(); const { childId, child, familyId } = useFamily();
@ -13,6 +36,7 @@ export default function GrowthPage() {
const [weight, setWeight] = useState(""); const [weight, setWeight] = useState("");
const [height, setHeight] = useState(""); const [height, setHeight] = useState("");
const [headCircumference, setHeadCircumference] = useState(""); const [headCircumference, setHeadCircumference] = useState("");
const [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
useEffect(() => { useEffect(() => {
if (!childId) return; if (!childId) return;
@ -68,6 +92,68 @@ export default function GrowthPage() {
const headPercentile = latest?.head_circumference_cm && whoStandard ? const headPercentile = latest?.head_circumference_cm && whoStandard ?
getPercentile(latest.head_circumference_cm, whoStandard.headCircumference.p3, whoStandard.headCircumference.p15, whoStandard.headCircumference.p50, whoStandard.headCircumference.p85, whoStandard.headCircumference.p97) : null; getPercentile(latest.head_circumference_cm, whoStandard.headCircumference.p3, whoStandard.headCircumference.p15, whoStandard.headCircumference.p50, whoStandard.headCircumference.p85, whoStandard.headCircumference.p97) : null;
// Prepare chart data
const chartData = (() => {
if (growthData.length === 0 || !child?.birthDate) return null;
const metricKey = chartMetric === "weight" ? "weight_kg" : chartMetric === "height" ? "height_cm" : "head_circumference_cm";
const whoKey = chartMetric === "weight" ? "weight" : chartMetric === "height" ? "height" : "headCircumference";
// Sort records by date (oldest first)
const sorted = [...growthData].reverse();
const labels = sorted.map(r => {
const age = getAgeInMonthsFromBirth(child.birthDate);
const measured = new Date(r.measured_at);
const birth = new Date(child.birthDate);
const months = Math.floor((measured.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 30));
return `${months}mo`;
});
const childData = sorted.map(r => r[metricKey]).filter(Boolean);
return {
labels,
datasets: [
{
label: child.name,
data: childData,
borderColor: "#fb7185",
backgroundColor: "rgba(251, 113, 133, 0.1)",
fill: true,
tension: 0.3,
},
{
label: "WHO 50th",
data: labels.map(() => whoStandard?.[whoKey]?.p50),
borderColor: "#22c55e",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
},
{
label: "WHO 3rd-97th",
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
borderColor: "transparent",
backgroundColor: "rgba(34, 197, 94, 0.1)",
fill: "+1",
pointRadius: 0,
},
],
};
})();
const chartOptions = {
responsive: true,
plugins: {
legend: { display: true, position: "top" as const },
title: { display: false },
},
scales: {
y: { title: { display: true, text: chartMetric === "weight" ? "Weight (kg)" : "cm" } },
},
};
if (!familyId) { if (!familyId) {
return <div className="p-4">Please log in to view growth records.</div>; return <div className="p-4">Please log in to view growth records.</div>;
} }
@ -111,6 +197,36 @@ export default function GrowthPage() {
</div> </div>
)} )}
{/* Growth Chart */}
{chartData && growthData.length > 0 && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
<div className="flex justify-between items-center mb-3">
<h2 className="font-semibold">Growth Chart</h2>
<div className="flex gap-1">
<button
onClick={() => setChartMetric("weight")}
className={`px-2 py-1 text-xs rounded ${chartMetric === "weight" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
>
Weight
</button>
<button
onClick={() => setChartMetric("height")}
className={`px-2 py-1 text-xs rounded ${chartMetric === "height" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
>
Height
</button>
<button
onClick={() => setChartMetric("head")}
className={`px-2 py-1 text-xs rounded ${chartMetric === "head" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
>
Head
</button>
</div>
</div>
<Line data={chartData} options={chartOptions} />
</div>
)}
{/* Latest Reading with Percentile */} {/* Latest Reading with Percentile */}
{latest && ( {latest && (
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl"> <div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">