mirror of
https://github.com/Oak-and-Sprout/sprout-track.git
synced 2026-01-06 16:30:27 -06:00
checkpoint adding growth chart
This commit is contained in:
141
app/api/cdc-growth-data/route.ts
Normal file
141
app/api/cdc-growth-data/route.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import prisma from '../db';
|
||||
import { ApiResponse } from '../types';
|
||||
import { withAuthContext, AuthResult } from '../utils/auth';
|
||||
|
||||
// CDC growth data record type
|
||||
export interface CdcGrowthDataRecord {
|
||||
sex: number;
|
||||
ageMonths: number;
|
||||
l: number;
|
||||
m: number;
|
||||
s: number;
|
||||
p3: number;
|
||||
p5: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p50: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
p95: number;
|
||||
p97: number;
|
||||
}
|
||||
|
||||
type MeasurementTypeParam = 'weight' | 'length' | 'head_circumference';
|
||||
|
||||
async function handleGet(req: NextRequest, authContext: AuthResult) {
|
||||
try {
|
||||
const { familyId: userFamilyId } = authContext;
|
||||
if (!userFamilyId) {
|
||||
return NextResponse.json<ApiResponse<null>>({ success: false, error: 'User is not associated with a family.' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const sex = searchParams.get('sex'); // 1 = Male, 2 = Female
|
||||
const measurementType = searchParams.get('type') as MeasurementTypeParam | null; // weight, length, head_circumference
|
||||
|
||||
if (!sex || !measurementType) {
|
||||
return NextResponse.json<ApiResponse<null>>(
|
||||
{ success: false, error: 'sex and type parameters are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const sexNum = parseInt(sex, 10);
|
||||
if (sexNum !== 1 && sexNum !== 2) {
|
||||
return NextResponse.json<ApiResponse<null>>(
|
||||
{ success: false, error: 'sex must be 1 (Male) or 2 (Female)' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let data: CdcGrowthDataRecord[];
|
||||
|
||||
switch (measurementType) {
|
||||
case 'weight':
|
||||
data = await prisma.cdcWeightForAge.findMany({
|
||||
where: { sex: sexNum },
|
||||
orderBy: { ageMonths: 'asc' },
|
||||
select: {
|
||||
sex: true,
|
||||
ageMonths: true,
|
||||
l: true,
|
||||
m: true,
|
||||
s: true,
|
||||
p3: true,
|
||||
p5: true,
|
||||
p10: true,
|
||||
p25: true,
|
||||
p50: true,
|
||||
p75: true,
|
||||
p90: true,
|
||||
p95: true,
|
||||
p97: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'length':
|
||||
data = await prisma.cdcLengthForAge.findMany({
|
||||
where: { sex: sexNum },
|
||||
orderBy: { ageMonths: 'asc' },
|
||||
select: {
|
||||
sex: true,
|
||||
ageMonths: true,
|
||||
l: true,
|
||||
m: true,
|
||||
s: true,
|
||||
p3: true,
|
||||
p5: true,
|
||||
p10: true,
|
||||
p25: true,
|
||||
p50: true,
|
||||
p75: true,
|
||||
p90: true,
|
||||
p95: true,
|
||||
p97: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'head_circumference':
|
||||
data = await prisma.cdcHeadCircumferenceForAge.findMany({
|
||||
where: { sex: sexNum },
|
||||
orderBy: { ageMonths: 'asc' },
|
||||
select: {
|
||||
sex: true,
|
||||
ageMonths: true,
|
||||
l: true,
|
||||
m: true,
|
||||
s: true,
|
||||
p3: true,
|
||||
p5: true,
|
||||
p10: true,
|
||||
p25: true,
|
||||
p50: true,
|
||||
p75: true,
|
||||
p90: true,
|
||||
p95: true,
|
||||
p97: true,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return NextResponse.json<ApiResponse<null>>(
|
||||
{ success: false, error: 'Invalid measurement type. Use: weight, length, or head_circumference' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json<ApiResponse<CdcGrowthDataRecord[]>>({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching CDC growth data:', error);
|
||||
return NextResponse.json<ApiResponse<null>>(
|
||||
{ success: false, error: 'Failed to fetch CDC growth data' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuthContext(handleGet as (req: NextRequest, authContext: AuthResult) => Promise<NextResponse<ApiResponse<any>>>);
|
||||
386
package-lock.json
generated
386
package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"prisma": "^6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.6.0",
|
||||
"stripe": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
@@ -3224,6 +3225,42 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -3937,6 +3974,12 @@
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "8.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.5.3.tgz",
|
||||
@@ -4331,6 +4374,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -4421,6 +4527,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||
@@ -5815,6 +5927,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -5923,6 +6156,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -6312,6 +6551,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz",
|
||||
"integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
|
||||
@@ -6819,6 +7068,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/execa": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz",
|
||||
@@ -7539,6 +7794,16 @@
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -7587,6 +7852,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -9612,9 +9886,31 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
|
||||
@@ -9728,6 +10024,51 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz",
|
||||
"integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
@@ -9772,6 +10113,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -10554,6 +10901,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
@@ -10980,6 +11333,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -10993,6 +11355,28 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/wcwidth": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"prisma": "^6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.6.0",
|
||||
"stripe": "^19.2.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
|
||||
957
src/components/Reports/GrowthChart.tsx
Normal file
957
src/components/Reports/GrowthChart.tsx
Normal file
@@ -0,0 +1,957 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Scale, Ruler, CircleDot, Loader2, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { useBaby } from '@/app/context/baby';
|
||||
import { growthChartStyles } from './growth-chart.styles';
|
||||
|
||||
// Types
|
||||
export type GrowthMeasurementType = 'weight' | 'length' | 'head_circumference';
|
||||
|
||||
interface CdcGrowthDataRecord {
|
||||
sex: number;
|
||||
ageMonths: number;
|
||||
l: number;
|
||||
m: number;
|
||||
s: number;
|
||||
p3: number;
|
||||
p5: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p50: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
p95: number;
|
||||
p97: number;
|
||||
}
|
||||
|
||||
interface MeasurementData {
|
||||
id: string;
|
||||
babyId: string;
|
||||
date: string;
|
||||
type: 'HEIGHT' | 'WEIGHT' | 'HEAD_CIRCUMFERENCE' | 'TEMPERATURE';
|
||||
value: number;
|
||||
unit: string;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface Settings {
|
||||
defaultWeightUnit: string;
|
||||
defaultHeightUnit: string;
|
||||
}
|
||||
|
||||
interface ChartDataPoint {
|
||||
ageMonths: number;
|
||||
p3: number;
|
||||
p5: number;
|
||||
p10: number;
|
||||
p25: number;
|
||||
p50: number;
|
||||
p75: number;
|
||||
p90: number;
|
||||
p95: number;
|
||||
p97: number;
|
||||
measurement?: number;
|
||||
measurementDate?: string;
|
||||
percentile?: number;
|
||||
}
|
||||
|
||||
interface MeasurementWithPercentile {
|
||||
ageMonths: number;
|
||||
value: number;
|
||||
displayValue: number;
|
||||
date: string;
|
||||
percentile: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
interface GrowthChartProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Helper to convert Gender enum to CDC sex number
|
||||
const genderToCdcSex = (gender: string | null | undefined): number => {
|
||||
if (gender === 'MALE') return 1;
|
||||
if (gender === 'FEMALE') return 2;
|
||||
return 1; // Default to male if unknown
|
||||
};
|
||||
|
||||
// Helper to calculate age in months from birth date
|
||||
const calculateAgeInMonths = (birthDate: string, measurementDate: string): number => {
|
||||
const birth = new Date(birthDate);
|
||||
const measurement = new Date(measurementDate);
|
||||
|
||||
const years = measurement.getFullYear() - birth.getFullYear();
|
||||
const months = measurement.getMonth() - birth.getMonth();
|
||||
const days = measurement.getDate() - birth.getDate();
|
||||
|
||||
let totalMonths = years * 12 + months;
|
||||
if (days < 0) {
|
||||
totalMonths -= 1;
|
||||
}
|
||||
|
||||
// Add fractional month based on day of month
|
||||
const daysInMonth = new Date(measurement.getFullYear(), measurement.getMonth() + 1, 0).getDate();
|
||||
const dayFraction = (days >= 0 ? days : daysInMonth + days) / daysInMonth;
|
||||
|
||||
return Math.max(0, totalMonths + dayFraction);
|
||||
};
|
||||
|
||||
// Helper to convert measurement values to CDC standard units (kg for weight, cm for length)
|
||||
const convertToCdcUnit = (value: number, unit: string, type: GrowthMeasurementType): number => {
|
||||
// Normalize unit to uppercase for comparison
|
||||
const normalizedUnit = (unit || '').toUpperCase().trim();
|
||||
|
||||
switch (type) {
|
||||
case 'weight':
|
||||
// CDC uses kg
|
||||
if (normalizedUnit === 'LB') return value * 0.453592;
|
||||
if (normalizedUnit === 'OZ') return value * 0.0283495;
|
||||
if (normalizedUnit === 'G') return value / 1000;
|
||||
if (normalizedUnit === 'KG') return value;
|
||||
// Default: assume kg if no recognized unit
|
||||
return value;
|
||||
case 'length':
|
||||
case 'head_circumference':
|
||||
// CDC uses cm
|
||||
if (normalizedUnit === 'IN') return value * 2.54;
|
||||
if (normalizedUnit === 'CM') return value;
|
||||
// Default: assume cm if no recognized unit
|
||||
return value;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to convert CDC units (kg, cm) to display units based on settings
|
||||
const convertFromCdcToDisplayUnit = (value: number, type: GrowthMeasurementType, displayUnit: string): number => {
|
||||
// Normalize displayUnit to uppercase for comparison
|
||||
const normalizedDisplayUnit = (displayUnit || '').toUpperCase().trim();
|
||||
|
||||
switch (type) {
|
||||
case 'weight':
|
||||
// CDC uses kg, convert to display unit
|
||||
if (normalizedDisplayUnit === 'LB') return value / 0.453592;
|
||||
if (normalizedDisplayUnit === 'OZ') return value / 0.0283495;
|
||||
if (normalizedDisplayUnit === 'G') return value * 1000;
|
||||
return value; // Keep kg
|
||||
case 'length':
|
||||
case 'head_circumference':
|
||||
// CDC uses cm, convert to display unit
|
||||
if (normalizedDisplayUnit === 'IN') return value / 2.54;
|
||||
return value; // Keep cm
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate percentile using CDC LMS method
|
||||
// Formula: Z = ((value/M)^L - 1) / (L * S) for L != 0
|
||||
// Then convert Z-score to percentile using normal distribution
|
||||
const calculatePercentile = (value: number, l: number, m: number, s: number): number => {
|
||||
if (m === 0 || s === 0) return 50; // Default to 50th if invalid
|
||||
|
||||
let zScore: number;
|
||||
if (l === 0) {
|
||||
// Special case when L = 0, use logarithm
|
||||
zScore = Math.log(value / m) / s;
|
||||
} else {
|
||||
zScore = (Math.pow(value / m, l) - 1) / (l * s);
|
||||
}
|
||||
|
||||
// Convert Z-score to percentile using error function approximation
|
||||
// P(Z < z) = 0.5 * (1 + erf(z / sqrt(2)))
|
||||
const percentile = 0.5 * (1 + erf(zScore / Math.sqrt(2))) * 100;
|
||||
|
||||
// Clamp to 0-100 and round to 1 decimal
|
||||
return Math.round(Math.max(0.1, Math.min(99.9, percentile)) * 10) / 10;
|
||||
};
|
||||
|
||||
// Error function approximation for normal distribution
|
||||
const erf = (x: number): number => {
|
||||
// Horner form coefficients
|
||||
const a1 = 0.254829592;
|
||||
const a2 = -0.284496736;
|
||||
const a3 = 1.421413741;
|
||||
const a4 = -1.453152027;
|
||||
const a5 = 1.061405429;
|
||||
const p = 0.3275911;
|
||||
|
||||
const sign = x < 0 ? -1 : 1;
|
||||
x = Math.abs(x);
|
||||
|
||||
const t = 1.0 / (1.0 + p * x);
|
||||
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x);
|
||||
|
||||
return sign * y;
|
||||
};
|
||||
|
||||
// Find CDC data point for a given age (interpolating if needed)
|
||||
const findCdcDataForAge = (cdcData: CdcGrowthDataRecord[], ageMonths: number): CdcGrowthDataRecord | null => {
|
||||
if (!cdcData.length) return null;
|
||||
|
||||
// Find surrounding points
|
||||
let lower: CdcGrowthDataRecord | null = null;
|
||||
let upper: CdcGrowthDataRecord | null = null;
|
||||
|
||||
for (let i = 0; i < cdcData.length; i++) {
|
||||
if (cdcData[i].ageMonths <= ageMonths) {
|
||||
lower = cdcData[i];
|
||||
}
|
||||
if (cdcData[i].ageMonths >= ageMonths && !upper) {
|
||||
upper = cdcData[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!lower && !upper) return null;
|
||||
if (!lower) return upper;
|
||||
if (!upper) return lower;
|
||||
if (lower.ageMonths === upper.ageMonths) return lower;
|
||||
|
||||
// Interpolate
|
||||
const ratio = (ageMonths - lower.ageMonths) / (upper.ageMonths - lower.ageMonths);
|
||||
return {
|
||||
sex: lower.sex,
|
||||
ageMonths: ageMonths,
|
||||
l: lower.l + ratio * (upper.l - lower.l),
|
||||
m: lower.m + ratio * (upper.m - lower.m),
|
||||
s: lower.s + ratio * (upper.s - lower.s),
|
||||
p3: lower.p3 + ratio * (upper.p3 - lower.p3),
|
||||
p5: lower.p5 + ratio * (upper.p5 - lower.p5),
|
||||
p10: lower.p10 + ratio * (upper.p10 - lower.p10),
|
||||
p25: lower.p25 + ratio * (upper.p25 - lower.p25),
|
||||
p50: lower.p50 + ratio * (upper.p50 - lower.p50),
|
||||
p75: lower.p75 + ratio * (upper.p75 - lower.p75),
|
||||
p90: lower.p90 + ratio * (upper.p90 - lower.p90),
|
||||
p95: lower.p95 + ratio * (upper.p95 - lower.p95),
|
||||
p97: lower.p97 + ratio * (upper.p97 - lower.p97),
|
||||
};
|
||||
};
|
||||
|
||||
// Map measurement API type to chart type
|
||||
const mapMeasurementType = (apiType: string): GrowthMeasurementType | null => {
|
||||
switch (apiType) {
|
||||
case 'WEIGHT':
|
||||
return 'weight';
|
||||
case 'HEIGHT':
|
||||
return 'length';
|
||||
case 'HEAD_CIRCUMFERENCE':
|
||||
return 'head_circumference';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Get unit display label based on settings
|
||||
const getUnitLabel = (type: GrowthMeasurementType, settings: Settings | null): string => {
|
||||
if (!settings) {
|
||||
// Default to metric
|
||||
switch (type) {
|
||||
case 'weight': return 'kg';
|
||||
case 'length':
|
||||
case 'head_circumference': return 'cm';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'weight':
|
||||
return settings.defaultWeightUnit === 'LB' ? 'lb' : 'kg';
|
||||
case 'length':
|
||||
case 'head_circumference':
|
||||
return settings.defaultHeightUnit === 'IN' ? 'in' : 'cm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Get display unit code from settings
|
||||
const getDisplayUnit = (type: GrowthMeasurementType, settings: Settings | null): string => {
|
||||
if (!settings) {
|
||||
switch (type) {
|
||||
case 'weight': return 'KG';
|
||||
case 'length':
|
||||
case 'head_circumference': return 'CM';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'weight':
|
||||
return settings.defaultWeightUnit || 'KG';
|
||||
case 'length':
|
||||
case 'head_circumference':
|
||||
return settings.defaultHeightUnit || 'CM';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// Custom tooltip component
|
||||
const CustomTooltip = ({ active, payload, label, settings, measurementType }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
const measurementPoint = payload.find((p: any) => p.dataKey === 'measurement');
|
||||
const dataPoint = payload[0]?.payload as ChartDataPoint;
|
||||
const unitLabel = getUnitLabel(measurementType, settings);
|
||||
|
||||
return (
|
||||
<div className={cn(growthChartStyles.tooltip, "growth-chart-tooltip")}>
|
||||
<p className={cn(growthChartStyles.tooltipLabel, "growth-chart-tooltip-label")}>
|
||||
Age: {typeof label === 'number' ? label.toFixed(1) : label} months
|
||||
</p>
|
||||
{measurementPoint && measurementPoint.value !== null && measurementPoint.value !== undefined && (
|
||||
<>
|
||||
<p className={cn(growthChartStyles.tooltipMeasurement, "growth-chart-tooltip-measurement")}>
|
||||
Measurement: {measurementPoint.value.toFixed(2)} {unitLabel}
|
||||
</p>
|
||||
{dataPoint?.percentile !== undefined && (
|
||||
<p className={cn(growthChartStyles.tooltipPercentile, "growth-chart-tooltip-percentile")}>
|
||||
Percentile: {dataPoint.percentile.toFixed(1)}%
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className={cn(growthChartStyles.tooltipPercentiles, "growth-chart-tooltip-percentiles")}>
|
||||
{payload
|
||||
.filter((p: any) => p.dataKey !== 'measurement' && p.dataKey !== 'percentile' && p.value)
|
||||
.slice(0, 5)
|
||||
.map((entry: any, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {entry.value?.toFixed(2)} {unitLabel}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const GrowthChart: React.FC<GrowthChartProps> = ({ className }) => {
|
||||
const { selectedBaby } = useBaby();
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// State
|
||||
const [measurementType, setMeasurementType] = useState<GrowthMeasurementType>('weight');
|
||||
const [cdcData, setCdcData] = useState<CdcGrowthDataRecord[]>([]);
|
||||
const [measurements, setMeasurements] = useState<MeasurementData[]>([]);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Zoom state
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [initialPinchDistance, setInitialPinchDistance] = useState<number | null>(null);
|
||||
const [initialZoom, setInitialZoom] = useState(1);
|
||||
|
||||
// Fetch settings
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
const response = await fetch('/api/settings', {
|
||||
headers: {
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
setSettings({
|
||||
defaultWeightUnit: data.data.defaultWeightUnit || 'LB',
|
||||
defaultHeightUnit: data.data.defaultHeightUnit || 'IN',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching settings:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
// Fetch CDC data when measurement type or baby gender changes
|
||||
useEffect(() => {
|
||||
const fetchCdcData = async () => {
|
||||
if (!selectedBaby) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
const sex = genderToCdcSex(selectedBaby.gender);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/cdc-growth-data?sex=${sex}&type=${measurementType}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCdcData(data.data || []);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch CDC data');
|
||||
}
|
||||
} else {
|
||||
setError('Failed to fetch CDC data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching CDC data:', err);
|
||||
setError('Error fetching CDC data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCdcData();
|
||||
}, [selectedBaby, measurementType]);
|
||||
|
||||
// Fetch baby measurements
|
||||
useEffect(() => {
|
||||
const fetchMeasurements = async () => {
|
||||
if (!selectedBaby) return;
|
||||
|
||||
try {
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
|
||||
// Map chart type to API type
|
||||
const apiType = measurementType === 'weight' ? 'WEIGHT'
|
||||
: measurementType === 'length' ? 'HEIGHT'
|
||||
: 'HEAD_CIRCUMFERENCE';
|
||||
|
||||
const response = await fetch(
|
||||
`/api/measurement-log?babyId=${selectedBaby.id}&type=${apiType}`,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authToken ? `Bearer ${authToken}` : '',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setMeasurements(data.data || []);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching measurements:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMeasurements();
|
||||
}, [selectedBaby, measurementType]);
|
||||
|
||||
// Process measurements with percentiles
|
||||
const measurementsWithPercentiles = useMemo((): MeasurementWithPercentile[] => {
|
||||
if (!cdcData.length || !selectedBaby?.birthDate) return [];
|
||||
|
||||
const displayUnit = getDisplayUnit(measurementType, settings);
|
||||
|
||||
return measurements
|
||||
.filter(m => mapMeasurementType(m.type) === measurementType)
|
||||
.map(m => {
|
||||
const ageMonths = calculateAgeInMonths(selectedBaby.birthDate!.toString(), m.date);
|
||||
|
||||
// Convert to CDC units for percentile calculation
|
||||
const cdcValue = convertToCdcUnit(m.value, m.unit, measurementType);
|
||||
|
||||
// Find CDC data for this age
|
||||
const cdcPoint = findCdcDataForAge(cdcData, ageMonths);
|
||||
|
||||
// Calculate percentile
|
||||
const percentile = cdcPoint
|
||||
? calculatePercentile(cdcValue, cdcPoint.l, cdcPoint.m, cdcPoint.s)
|
||||
: 50;
|
||||
|
||||
// Convert to display unit
|
||||
const displayValue = convertFromCdcToDisplayUnit(cdcValue, measurementType, displayUnit);
|
||||
|
||||
return {
|
||||
ageMonths,
|
||||
value: cdcValue,
|
||||
displayValue,
|
||||
date: m.date,
|
||||
percentile,
|
||||
unit: displayUnit,
|
||||
};
|
||||
})
|
||||
.filter(m => m.ageMonths >= 0 && m.ageMonths <= 36.5) // Filter to CDC range
|
||||
.sort((a, b) => a.ageMonths - b.ageMonths);
|
||||
}, [cdcData, measurements, measurementType, selectedBaby, settings]);
|
||||
|
||||
// Calculate baby's current age in months
|
||||
const babyCurrentAgeMonths = useMemo((): number => {
|
||||
if (!selectedBaby?.birthDate) return 12; // Default to 12 months if no birthdate
|
||||
|
||||
const now = new Date();
|
||||
const birth = new Date(selectedBaby.birthDate);
|
||||
|
||||
const years = now.getFullYear() - birth.getFullYear();
|
||||
const months = now.getMonth() - birth.getMonth();
|
||||
const days = now.getDate() - birth.getDate();
|
||||
|
||||
let totalMonths = years * 12 + months;
|
||||
if (days < 0) {
|
||||
totalMonths -= 1;
|
||||
}
|
||||
|
||||
// Add 1 month buffer and round up to nearest month
|
||||
const ageWithBuffer = Math.ceil(totalMonths + 1);
|
||||
|
||||
// Minimum of 3 months, maximum of 36 months
|
||||
return Math.max(3, Math.min(36, ageWithBuffer));
|
||||
}, [selectedBaby]);
|
||||
|
||||
// Combine CDC data with baby measurements for chart
|
||||
const chartData = useMemo((): ChartDataPoint[] => {
|
||||
if (!cdcData.length || !selectedBaby?.birthDate) return [];
|
||||
|
||||
const displayUnit = getDisplayUnit(measurementType, settings);
|
||||
|
||||
// Create base data from CDC percentiles, converted to display units
|
||||
// Filter to only include data up to baby's current age + 1 month
|
||||
const baseData: ChartDataPoint[] = cdcData
|
||||
.filter(record => record.ageMonths <= babyCurrentAgeMonths)
|
||||
.map(record => ({
|
||||
ageMonths: record.ageMonths,
|
||||
p3: convertFromCdcToDisplayUnit(record.p3, measurementType, displayUnit),
|
||||
p5: convertFromCdcToDisplayUnit(record.p5, measurementType, displayUnit),
|
||||
p10: convertFromCdcToDisplayUnit(record.p10, measurementType, displayUnit),
|
||||
p25: convertFromCdcToDisplayUnit(record.p25, measurementType, displayUnit),
|
||||
p50: convertFromCdcToDisplayUnit(record.p50, measurementType, displayUnit),
|
||||
p75: convertFromCdcToDisplayUnit(record.p75, measurementType, displayUnit),
|
||||
p90: convertFromCdcToDisplayUnit(record.p90, measurementType, displayUnit),
|
||||
p95: convertFromCdcToDisplayUnit(record.p95, measurementType, displayUnit),
|
||||
p97: convertFromCdcToDisplayUnit(record.p97, measurementType, displayUnit),
|
||||
}));
|
||||
|
||||
// Create a map for measurement points
|
||||
const measurementPointsMap: Map<number, { value: number; date: string; percentile: number }> = new Map();
|
||||
|
||||
measurementsWithPercentiles.forEach(m => {
|
||||
// Round to nearest 0.5 month for matching with CDC data
|
||||
const roundedAge = Math.round(m.ageMonths * 2) / 2;
|
||||
measurementPointsMap.set(roundedAge, {
|
||||
value: m.displayValue,
|
||||
date: m.date,
|
||||
percentile: m.percentile,
|
||||
});
|
||||
});
|
||||
|
||||
// Merge measurement points into chart data
|
||||
const mergedData = baseData.map(point => {
|
||||
const measurement = measurementPointsMap.get(point.ageMonths);
|
||||
if (measurement) {
|
||||
measurementPointsMap.delete(point.ageMonths); // Mark as used
|
||||
return {
|
||||
...point,
|
||||
measurement: measurement.value,
|
||||
measurementDate: measurement.date,
|
||||
percentile: measurement.percentile,
|
||||
};
|
||||
}
|
||||
return point;
|
||||
});
|
||||
|
||||
// Add any remaining measurement points that don't align with CDC data points
|
||||
measurementPointsMap.forEach((measurement, age) => {
|
||||
// Find surrounding CDC points and interpolate
|
||||
const lowerIdx = baseData.findIndex(d => d.ageMonths > age) - 1;
|
||||
if (lowerIdx >= 0 && lowerIdx < baseData.length - 1) {
|
||||
const lower = baseData[lowerIdx];
|
||||
const upper = baseData[lowerIdx + 1];
|
||||
const ratio = (age - lower.ageMonths) / (upper.ageMonths - lower.ageMonths);
|
||||
|
||||
mergedData.push({
|
||||
ageMonths: age,
|
||||
p3: lower.p3 + ratio * (upper.p3 - lower.p3),
|
||||
p5: lower.p5 + ratio * (upper.p5 - lower.p5),
|
||||
p10: lower.p10 + ratio * (upper.p10 - lower.p10),
|
||||
p25: lower.p25 + ratio * (upper.p25 - lower.p25),
|
||||
p50: lower.p50 + ratio * (upper.p50 - lower.p50),
|
||||
p75: lower.p75 + ratio * (upper.p75 - lower.p75),
|
||||
p90: lower.p90 + ratio * (upper.p90 - lower.p90),
|
||||
p95: lower.p95 + ratio * (upper.p95 - lower.p95),
|
||||
p97: lower.p97 + ratio * (upper.p97 - lower.p97),
|
||||
measurement: measurement.value,
|
||||
measurementDate: measurement.date,
|
||||
percentile: measurement.percentile,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sort by age
|
||||
return mergedData.sort((a, b) => a.ageMonths - b.ageMonths);
|
||||
}, [cdcData, measurementsWithPercentiles, measurementType, selectedBaby, settings, babyCurrentAgeMonths]);
|
||||
|
||||
// Zoom handlers
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoomLevel(prev => Math.min(prev * 1.5, 5));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoomLevel(prev => Math.max(prev / 1.5, 1));
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setZoomLevel(1);
|
||||
setPanOffset({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
// Mouse/touch handlers for pan
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (zoomLevel > 1) {
|
||||
setIsDragging(true);
|
||||
setDragStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y });
|
||||
}
|
||||
}, [zoomLevel, panOffset]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (isDragging && zoomLevel > 1) {
|
||||
const maxPan = (zoomLevel - 1) * 200;
|
||||
setPanOffset({
|
||||
x: Math.max(-maxPan, Math.min(maxPan, e.clientX - dragStart.x)),
|
||||
y: Math.max(-maxPan, Math.min(maxPan, e.clientY - dragStart.y)),
|
||||
});
|
||||
}
|
||||
}, [isDragging, zoomLevel, dragStart]);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Touch handlers for pinch zoom
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const distance = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
setInitialPinchDistance(distance);
|
||||
setInitialZoom(zoomLevel);
|
||||
} else if (e.touches.length === 1 && zoomLevel > 1) {
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.touches[0].clientX - panOffset.x,
|
||||
y: e.touches[0].clientY - panOffset.y
|
||||
});
|
||||
}
|
||||
}, [zoomLevel, panOffset]);
|
||||
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2 && initialPinchDistance !== null) {
|
||||
const distance = Math.hypot(
|
||||
e.touches[0].clientX - e.touches[1].clientX,
|
||||
e.touches[0].clientY - e.touches[1].clientY
|
||||
);
|
||||
const scale = distance / initialPinchDistance;
|
||||
setZoomLevel(Math.max(1, Math.min(5, initialZoom * scale)));
|
||||
} else if (e.touches.length === 1 && isDragging && zoomLevel > 1) {
|
||||
const maxPan = (zoomLevel - 1) * 200;
|
||||
setPanOffset({
|
||||
x: Math.max(-maxPan, Math.min(maxPan, e.touches[0].clientX - dragStart.x)),
|
||||
y: Math.max(-maxPan, Math.min(maxPan, e.touches[0].clientY - dragStart.y)),
|
||||
});
|
||||
}
|
||||
}, [initialPinchDistance, initialZoom, isDragging, zoomLevel, dragStart]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setInitialPinchDistance(null);
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// Wheel zoom handler
|
||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
setZoomLevel(prev => Math.max(1, Math.min(5, prev * delta)));
|
||||
}, []);
|
||||
|
||||
// Get measurement type button config
|
||||
const measurementTypes: { type: GrowthMeasurementType; label: string; icon: React.ReactNode }[] = [
|
||||
{ type: 'weight', label: 'Weight', icon: <Scale className="h-4 w-4" /> },
|
||||
{ type: 'length', label: 'Length', icon: <Ruler className="h-4 w-4" /> },
|
||||
{ type: 'head_circumference', label: 'Head', icon: <CircleDot className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
// No baby selected
|
||||
if (!selectedBaby) {
|
||||
return (
|
||||
<div className={cn(growthChartStyles.emptyContainer, "growth-chart-empty", className)}>
|
||||
<Scale className="h-12 w-12 text-gray-300 mb-4" />
|
||||
<p className={cn(growthChartStyles.emptyText, "growth-chart-empty-text")}>
|
||||
Select a baby to view growth charts.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn(growthChartStyles.loadingContainer, "growth-chart-loading", className)}>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
|
||||
<p className={cn(growthChartStyles.loadingText, "growth-chart-loading-text")}>
|
||||
Loading growth chart data...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn(growthChartStyles.errorContainer, "growth-chart-error", className)}>
|
||||
<p className={cn(growthChartStyles.errorText, "growth-chart-error-text")}>{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unitLabel = getUnitLabel(measurementType, settings);
|
||||
|
||||
return (
|
||||
<div className={cn(growthChartStyles.container, "growth-chart-container", className)}>
|
||||
{/* Measurement type toggle buttons */}
|
||||
<div className={cn(growthChartStyles.buttonGroup, "growth-chart-button-group")}>
|
||||
{measurementTypes.map(({ type, label, icon }) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setMeasurementType(type)}
|
||||
className={cn(
|
||||
growthChartStyles.button,
|
||||
"growth-chart-button",
|
||||
measurementType === type && growthChartStyles.buttonActive,
|
||||
measurementType === type && "growth-chart-button-active"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className={cn(growthChartStyles.zoomControls, "growth-chart-zoom-controls")}>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className={cn(growthChartStyles.zoomButton, "growth-chart-zoom-button")}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className={cn(growthChartStyles.zoomButton, "growth-chart-zoom-button")}
|
||||
title="Zoom out"
|
||||
disabled={zoomLevel <= 1}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className={cn(growthChartStyles.zoomButton, "growth-chart-zoom-button")}
|
||||
title="Reset zoom"
|
||||
disabled={zoomLevel === 1 && panOffset.x === 0 && panOffset.y === 0}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<span className={cn(growthChartStyles.zoomLabel, "growth-chart-zoom-label")}>
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Chart container with zoom/pan */}
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className={cn(growthChartStyles.chartWrapper, "growth-chart-wrapper")}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onWheel={handleWheel}
|
||||
style={{
|
||||
cursor: zoomLevel > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
|
||||
transformOrigin: 'center center',
|
||||
transition: isDragging ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 20, right: 30, left: 20, bottom: 30 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="growth-chart-grid" />
|
||||
<XAxis
|
||||
dataKey="ageMonths"
|
||||
label={{ value: 'Age (months)', position: 'insideBottom', offset: -5 }}
|
||||
tickFormatter={(value) => value.toString()}
|
||||
className="growth-chart-axis"
|
||||
/>
|
||||
<YAxis
|
||||
label={{ value: unitLabel, angle: -90, position: 'insideLeft' }}
|
||||
className="growth-chart-axis"
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip settings={settings} measurementType={measurementType} />} />
|
||||
|
||||
{/* Percentile lines - using gradient from light to dark */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p3"
|
||||
name="3rd"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p10"
|
||||
name="10th"
|
||||
stroke="#64748b"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p25"
|
||||
name="25th"
|
||||
stroke="#475569"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p50"
|
||||
name="50th"
|
||||
stroke="#14b8a6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p75"
|
||||
name="75th"
|
||||
stroke="#475569"
|
||||
strokeWidth={1.5}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p90"
|
||||
name="90th"
|
||||
stroke="#64748b"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="p97"
|
||||
name="97th"
|
||||
stroke="#94a3b8"
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
|
||||
{/* Baby's measurements */}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="measurement"
|
||||
name={`${selectedBaby.firstName}'s ${measurementType === 'head_circumference' ? 'head' : measurementType}`}
|
||||
stroke="#f97316"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 5 }}
|
||||
activeDot={{ r: 8, fill: '#ea580c' }}
|
||||
connectNulls={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Measurements list with percentiles */}
|
||||
{measurementsWithPercentiles.length > 0 && (
|
||||
<div className={cn(growthChartStyles.measurementsList, "growth-chart-measurements-list")}>
|
||||
<h4 className={cn(growthChartStyles.measurementsTitle, "growth-chart-measurements-title")}>
|
||||
Recorded Measurements
|
||||
</h4>
|
||||
<div className={cn(growthChartStyles.measurementsGrid, "growth-chart-measurements-grid")}>
|
||||
{measurementsWithPercentiles.map((m, idx) => (
|
||||
<div key={idx} className={cn(growthChartStyles.measurementItem, "growth-chart-measurement-item")}>
|
||||
<div className={cn(growthChartStyles.measurementValue, "growth-chart-measurement-value")}>
|
||||
{m.displayValue.toFixed(2)} {unitLabel}
|
||||
</div>
|
||||
<div className={cn(growthChartStyles.measurementPercentile, "growth-chart-measurement-percentile")}>
|
||||
{m.percentile.toFixed(1)}th percentile
|
||||
</div>
|
||||
<div className={cn(growthChartStyles.measurementAge, "growth-chart-measurement-age")}>
|
||||
{m.ageMonths.toFixed(1)} months
|
||||
</div>
|
||||
<div className={cn(growthChartStyles.measurementDate, "growth-chart-measurement-date")}>
|
||||
{new Date(m.date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend info */}
|
||||
<div className={cn(growthChartStyles.legendInfo, "growth-chart-legend-info")}>
|
||||
<p className={cn(growthChartStyles.legendText, "growth-chart-legend-text")}>
|
||||
CDC Growth Chart for {selectedBaby.gender === 'MALE' ? 'Boys' : 'Girls'} (Birth to {babyCurrentAgeMonths} months)
|
||||
</p>
|
||||
<p className={cn(growthChartStyles.legendSubtext, "growth-chart-legend-subtext")}>
|
||||
Percentile lines show how your baby compares to other children of the same age and sex.
|
||||
The 50th percentile represents the median.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* No measurements message */}
|
||||
{measurements.length === 0 && (
|
||||
<div className={cn(growthChartStyles.noDataMessage, "growth-chart-no-data")}>
|
||||
<p>No {measurementType === 'head_circumference' ? 'head circumference' : measurementType} measurements recorded yet.</p>
|
||||
<p className="text-sm mt-1">Add measurements to see how your baby is growing!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GrowthChart;
|
||||
@@ -1,31 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
import { cn } from '@/src/lib/utils';
|
||||
import { styles } from './reports.styles';
|
||||
import { GrowthTrendsTabProps } from './reports.types';
|
||||
import GrowthChart from './GrowthChart';
|
||||
|
||||
/**
|
||||
* GrowthTrendsTab Component
|
||||
*
|
||||
* Placeholder tab for displaying growth trends and measurements over time.
|
||||
* Will be implemented in a future update.
|
||||
* Displays growth charts with CDC percentile curves for tracking
|
||||
* baby's weight, length, and head circumference over time.
|
||||
* Ignores date range - always shows all measurements.
|
||||
*/
|
||||
const GrowthTrendsTab: React.FC<GrowthTrendsTabProps> = ({
|
||||
dateRange,
|
||||
isLoading
|
||||
}) => {
|
||||
const GrowthTrendsTab: React.FC<GrowthTrendsTabProps> = () => {
|
||||
return (
|
||||
<div className={cn(styles.placeholderContainer, "reports-placeholder-container")}>
|
||||
<TrendingUp className={cn(styles.placeholderIcon, "reports-placeholder-icon")} />
|
||||
<h3 className={cn(styles.placeholderTitle, "reports-placeholder-title")}>
|
||||
Growth Trends
|
||||
</h3>
|
||||
<p className={cn(styles.placeholderText, "reports-placeholder-text")}>
|
||||
Track your baby's growth over time with height, weight, and head circumference charts.
|
||||
This feature is coming soon!
|
||||
</p>
|
||||
<div className={cn("py-2", "growth-trends-tab-container")}>
|
||||
<GrowthChart />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
60
src/components/Reports/growth-chart.styles.ts
Normal file
60
src/components/Reports/growth-chart.styles.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Styles for the GrowthChart component
|
||||
*
|
||||
* Light mode styles defined here, dark mode overrides in reports.css
|
||||
*/
|
||||
|
||||
export const growthChartStyles = {
|
||||
// Main container
|
||||
container: "flex flex-col space-y-4",
|
||||
|
||||
// Button group for measurement type selection
|
||||
buttonGroup: "flex flex-wrap gap-2 justify-center",
|
||||
button: "flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-700 hover:bg-gray-50 transition-colors",
|
||||
buttonActive: "bg-teal-50 border-teal-500 text-teal-700 hover:bg-teal-100",
|
||||
|
||||
// Zoom controls
|
||||
zoomControls: "flex items-center gap-2 justify-end",
|
||||
zoomButton: "p-2 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:text-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
|
||||
zoomLabel: "text-sm text-gray-500 min-w-[3rem] text-center",
|
||||
|
||||
// Chart wrapper
|
||||
chartWrapper: "bg-white rounded-lg border border-gray-200 p-4 touch-none select-none",
|
||||
|
||||
// Loading state
|
||||
loadingContainer: "flex flex-col items-center justify-center py-12",
|
||||
loadingText: "mt-2 text-gray-600",
|
||||
|
||||
// Error state
|
||||
errorContainer: "flex flex-col items-center justify-center py-8",
|
||||
errorText: "text-red-500 mb-2",
|
||||
|
||||
// Empty state
|
||||
emptyContainer: "flex flex-col items-center justify-center py-12",
|
||||
emptyText: "text-gray-500 text-center",
|
||||
|
||||
// Tooltip styles
|
||||
tooltip: "bg-white border border-gray-200 rounded-lg shadow-lg p-3",
|
||||
tooltipLabel: "font-medium text-gray-800 mb-1",
|
||||
tooltipMeasurement: "text-orange-600 font-semibold",
|
||||
tooltipPercentile: "text-teal-600 font-medium mb-2",
|
||||
tooltipPercentiles: "text-xs space-y-0.5",
|
||||
|
||||
// Measurements list with percentiles
|
||||
measurementsList: "mt-4 bg-gray-50 rounded-lg border border-gray-100 p-4",
|
||||
measurementsTitle: "text-sm font-semibold text-gray-700 mb-3",
|
||||
measurementsGrid: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3",
|
||||
measurementItem: "bg-white rounded-lg border border-gray-200 p-3 shadow-sm",
|
||||
measurementValue: "text-lg font-bold text-gray-800",
|
||||
measurementPercentile: "text-sm font-medium text-teal-600",
|
||||
measurementAge: "text-xs text-gray-500 mt-1",
|
||||
measurementDate: "text-xs text-gray-400",
|
||||
|
||||
// Legend info
|
||||
legendInfo: "text-center pt-2",
|
||||
legendText: "text-sm font-medium text-gray-700",
|
||||
legendSubtext: "text-xs text-gray-500 mt-1 max-w-lg mx-auto",
|
||||
|
||||
// No data message
|
||||
noDataMessage: "text-center py-4 text-gray-500 bg-gray-50 rounded-lg border border-gray-100",
|
||||
};
|
||||
@@ -233,3 +233,187 @@ html.dark .reports-milestone-category {
|
||||
background-color: #374151 !important; /* gray-700 */
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
/* Growth Chart styles */
|
||||
html.dark .growth-chart-container {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.dark .growth-chart-button-group {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.dark .growth-chart-button {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
color: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-button:hover {
|
||||
background-color: #374151 !important; /* gray-700 */
|
||||
color: #f3f4f6 !important; /* gray-100 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-button-active {
|
||||
background-color: rgba(20, 184, 166, 0.15) !important; /* teal-500/15 */
|
||||
border-color: #14b8a6 !important; /* teal-500 */
|
||||
color: #5eead4 !important; /* teal-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-button-active:hover {
|
||||
background-color: rgba(20, 184, 166, 0.25) !important; /* teal-500/25 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-zoom-controls {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.dark .growth-chart-zoom-button {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-zoom-button:hover {
|
||||
background-color: #374151 !important; /* gray-700 */
|
||||
color: #e5e7eb !important; /* gray-200 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-zoom-label {
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
}
|
||||
|
||||
/* Chart elements */
|
||||
html.dark .growth-chart-grid line {
|
||||
stroke: #374151 !important; /* gray-700 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-axis text {
|
||||
fill: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-axis line,
|
||||
html.dark .growth-chart-axis path {
|
||||
stroke: #4b5563 !important; /* gray-600 */
|
||||
}
|
||||
|
||||
/* Recharts specific dark mode */
|
||||
html.dark .growth-chart-wrapper .recharts-cartesian-grid-horizontal line,
|
||||
html.dark .growth-chart-wrapper .recharts-cartesian-grid-vertical line {
|
||||
stroke: #374151 !important; /* gray-700 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper .recharts-cartesian-axis-tick-value {
|
||||
fill: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper .recharts-cartesian-axis-line {
|
||||
stroke: #4b5563 !important; /* gray-600 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper .recharts-label {
|
||||
fill: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper .recharts-legend-item-text {
|
||||
color: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-wrapper .recharts-default-legend {
|
||||
color: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
html.dark .growth-chart-tooltip {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
|
||||
html.dark .growth-chart-tooltip-label {
|
||||
color: #e5e7eb !important; /* gray-200 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-tooltip-measurement {
|
||||
color: #fb923c !important; /* orange-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-tooltip-percentiles {
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
html.dark .growth-chart-loading-text {
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
html.dark .growth-chart-error-text {
|
||||
color: #ef4444 !important; /* red-500 */
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
html.dark .growth-chart-empty-text {
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
/* Legend info */
|
||||
html.dark .growth-chart-legend-info {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html.dark .growth-chart-legend-text {
|
||||
color: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-legend-subtext {
|
||||
color: #6b7280 !important; /* gray-500 */
|
||||
}
|
||||
|
||||
/* No data message */
|
||||
html.dark .growth-chart-no-data {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
/* Tooltip percentile */
|
||||
html.dark .growth-chart-tooltip-percentile {
|
||||
color: #5eead4 !important; /* teal-300 */
|
||||
}
|
||||
|
||||
/* Measurements list */
|
||||
html.dark .growth-chart-measurements-list {
|
||||
background-color: #111827 !important; /* gray-900 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurements-title {
|
||||
color: #d1d5db !important; /* gray-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurement-item {
|
||||
background-color: #1f2937 !important; /* gray-800 */
|
||||
border-color: #374151 !important; /* gray-700 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurement-value {
|
||||
color: #f3f4f6 !important; /* gray-100 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurement-percentile {
|
||||
color: #5eead4 !important; /* teal-300 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurement-age {
|
||||
color: #9ca3af !important; /* gray-400 */
|
||||
}
|
||||
|
||||
html.dark .growth-chart-measurement-date {
|
||||
color: #6b7280 !important; /* gray-500 */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user