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",
|
"@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
30
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue