diff --git a/package.json b/package.json index ce078fb..6f924e6 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7cd9a8..cdaf01b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/growth/page.tsx b/src/app/growth/page.tsx index 54a6dae..5bded81 100644 --- a/src/app/growth/page.tsx +++ b/src/app/growth/page.tsx @@ -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