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:
parent
0865706a94
commit
387da42286
3 changed files with 149 additions and 1 deletions
|
|
@ -11,6 +11,7 @@
|
|||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1045.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"framer-motion": "^12.38.0",
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
"openai": "^6.37.0",
|
||||
"postgres": "^3.4.9",
|
||||
"react": "19.2.4",
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
|
|
|||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
|
@ -17,6 +17,9 @@ importers:
|
|||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.1045.0
|
||||
version: 3.1045.0
|
||||
chart.js:
|
||||
specifier: ^4.5.1
|
||||
version: 4.5.1
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
|
|
@ -47,6 +50,9 @@ importers:
|
|||
react:
|
||||
specifier: 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:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
|
|
@ -896,6 +902,9 @@ packages:
|
|||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@kurkle/color@0.3.4':
|
||||
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
|
||||
|
||||
'@next/env@16.2.6':
|
||||
resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==}
|
||||
|
||||
|
|
@ -1289,6 +1298,10 @@ packages:
|
|||
caniuse-lite@1.0.30001792:
|
||||
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:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
|
|
@ -1632,6 +1645,12 @@ packages:
|
|||
preact@10.24.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2555,6 +2574,8 @@ snapshots:
|
|||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@kurkle/color@0.3.4': {}
|
||||
|
||||
'@next/env@16.2.6': {}
|
||||
|
||||
'@next/swc-darwin-arm64@16.2.6':
|
||||
|
|
@ -3011,6 +3032,10 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001792: {}
|
||||
|
||||
chart.js@4.5.1:
|
||||
dependencies:
|
||||
'@kurkle/color': 0.3.4
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
|
@ -3279,6 +3304,11 @@ snapshots:
|
|||
|
||||
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):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
|
|
|||
|
|
@ -1,8 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFamily } from "../FamilyProvider";
|
||||
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() {
|
||||
const { childId, child, familyId } = useFamily();
|
||||
|
|
@ -13,6 +36,7 @@ export default function GrowthPage() {
|
|||
const [weight, setWeight] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
const [headCircumference, setHeadCircumference] = useState("");
|
||||
const [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
|
||||
|
||||
useEffect(() => {
|
||||
if (!childId) return;
|
||||
|
|
@ -68,6 +92,68 @@ export default function GrowthPage() {
|
|||
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;
|
||||
|
||||
// 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) {
|
||||
return <div className="p-4">Please log in to view growth records.</div>;
|
||||
}
|
||||
|
|
@ -111,6 +197,36 @@ export default function GrowthPage() {
|
|||
</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 && (
|
||||
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue