tia/src/app/growth/page.tsx
Mannu 318b277e44 feat(growth): add edit/delete, goals, CSV export, and WHO percentile enhancements
- Add PUT and DELETE endpoints for growth records
- Add CSV export for pediatrician visits
- Add goal tracking with localStorage persistence
- Color-coded percentiles (green/yellow/red zones)
- Show WHO percentile lines (15th, 50th, 85th) on chart
- Growth velocity indicator (kg/month between readings)
- Enhanced WHO standards card with actual vs target + goal progress
- Better empty state with encouraging prompt
- Fix UUID type for growth record IDs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 13:52:02 +05:30

659 lines
No EOL
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useEffect, useRef } from "react";
import { useFamily } from "../FamilyProvider";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } 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";
import { useTheme } from "../ThemeProvider";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface GrowthRecord {
id: string;
child_id: string;
measured_at: string;
weight_kg: number | null;
height_cm: number | null;
head_circumference_cm: number | null;
notes: string | null;
}
interface Goal {
weightKg?: number;
heightCm?: number;
targetDate?: string;
}
export default function GrowthPage() {
const { childId, child, familyId } = useFamily();
const { theme } = useTheme();
const isDark = theme === "dark";
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
const [whoStandard, setWhoStandard] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [showGoals, setShowGoals] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
// Form state
const [weight, setWeight] = useState("");
const [height, setHeight] = useState("");
const [headCircumference, setHeadCircumference] = useState("");
const [measuredAt, setMeasuredAt] = useState(new Date().toISOString().split("T")[0]);
// Chart state
const [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
// Goals state
const [goal, setGoal] = useState<Goal>({});
// Saved goals from localStorage
const [savedGoals, setSavedGoals] = useState<Goal>({});
useEffect(() => {
if (!childId) return;
fetchGrowthData();
}, [childId]);
useEffect(() => {
// Load saved goals for this child
if (childId) {
const saved = localStorage.getItem(`tia_growth_goal_${childId}`);
if (saved) {
const parsed = JSON.parse(saved);
setSavedGoals(parsed);
setGoal(parsed);
}
}
}, [childId]);
const fetchGrowthData = () => {
if (!childId) return;
fetch(`/api/growth?childId=${childId}`)
.then(r => r.json())
.then(data => {
setGrowthData(data.growth || []);
setWhoStandard(data.whoStandard);
setLoading(false);
})
.catch(() => setLoading(false));
};
const handleAdd = async () => {
if (!childId || (!weight && !height && !headCircumference)) return;
await fetch("/api/growth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
childId,
measuredAt: new Date(measuredAt).toISOString(),
weightKg: weight ? parseFloat(weight) : null,
heightCm: height ? parseFloat(height) : null,
headCircumferenceCm: headCircumference ? parseFloat(headCircumference) : null,
}),
});
resetForm();
fetchGrowthData();
};
const handleEdit = async (id: string) => {
await fetch("/api/growth", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id,
measuredAt: new Date(measuredAt).toISOString(),
weightKg: weight ? parseFloat(weight) : null,
heightCm: height ? parseFloat(height) : null,
headCircumferenceCm: headCircumference ? parseFloat(headCircumference) : null,
}),
});
setEditingId(null);
resetForm();
fetchGrowthData();
};
const handleDelete = async (id: string) => {
if (!confirm("Delete this record?")) return;
await fetch(`/api/growth?id=${id}`, { method: "DELETE" });
fetchGrowthData();
};
const resetForm = () => {
setShowAdd(false);
setEditingId(null);
setWeight("");
setHeight("");
setHeadCircumference("");
setMeasuredAt(new Date().toISOString().split("T")[0]);
};
const startEdit = (record: GrowthRecord) => {
setEditingId(record.id);
setShowAdd(true);
setWeight(record.weight_kg?.toString() || "");
setHeight(record.height_cm?.toString() || "");
setHeadCircumference(record.head_circumference_cm?.toString() || "");
setMeasuredAt(new Date(record.measured_at).toISOString().split("T")[0]);
};
const saveGoal = () => {
if (!childId) return;
localStorage.setItem(`tia_growth_goal_${childId}`, JSON.stringify(goal));
setSavedGoals(goal);
setShowGoals(false);
};
const exportCSV = () => {
if (growthData.length === 0) return;
const headers = ["Date", "Weight (kg)", "Height (cm)", "Head (cm)", "Notes"];
const rows = growthData.map(r => [
new Date(r.measured_at).toLocaleDateString(),
r.weight_kg || "",
r.height_cm || "",
r.head_circumference_cm || "",
r.notes || ""
]);
const csv = [headers, ...rows].map(row => row.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${child?.name || "child"}_growth_${new Date().toISOString().split("T")[0]}.csv`;
a.click();
};
// Get latest measurement
const latest = growthData[0];
// Calculate age in months for WHO standard
const ageMonths = child ? getAgeInMonthsFromBirth(child.birthDate) : 12;
const standards = child?.sex === "male" ? WHO_BOY_WEIGHT : WHO_GIRL_WEIGHT;
const standard = standards.find(s => s.ageMonths === Math.min(ageMonths, 24)) || standards[standards.length - 1];
// Get percentile color
const getPercentileColor = (p: string | null): string => {
if (!p) return "text-gray-500";
if (p.includes("Below") || p.includes("3rd")) return "text-red-500";
if (p.includes("85th") || p.includes("97th")) return "text-amber-500";
return "text-green-600";
};
// Calculate percentile for latest reading
const weightPercentile = latest?.weight_kg && whoStandard ?
getPercentile(latest.weight_kg, whoStandard.weight.p3, whoStandard.weight.p15, whoStandard.weight.p50, whoStandard.weight.p85, whoStandard.weight.p97) : null;
const heightPercentile = latest?.height_cm && whoStandard ?
getPercentile(latest.height_cm, whoStandard.height.p3, whoStandard.height.p15, whoStandard.height.p50, whoStandard.height.p85, whoStandard.height.p97) : null;
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;
// Calculate growth velocity (change between last two readings)
const velocity = (() => {
if (growthData.length < 2) return null;
const current = growthData[0];
const previous = growthData[1];
const daysDiff = (new Date(current.measured_at).getTime() - new Date(previous.measured_at).getTime()) / (1000 * 60 * 60 * 24);
if (!current.weight_kg || !previous.weight_kg || daysDiff < 1) return null;
const weightChange = current.weight_kg - previous.weight_kg;
const monthsDiff = daysDiff / 30;
const monthlyGain = weightChange / monthsDiff;
return { weight: monthlyGain.toFixed(2), direction: monthlyGain > 0 ? "up" : "down" };
})();
// 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 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,
pointRadius: 6,
pointBackgroundColor: "#fb7185",
},
// WHO 85th percentile
{
label: "85th",
data: labels.map(() => whoStandard?.[whoKey]?.p85),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 50th percentile
{
label: "50th",
data: labels.map(() => whoStandard?.[whoKey]?.p50),
borderColor: "#22c55e",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 15th percentile
{
label: "15th",
data: labels.map(() => whoStandard?.[whoKey]?.p15),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 3rd-97th band
{
label: "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, labels: { boxWidth: 10 } },
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>;
}
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-20">
<div className="p-4 flex justify-between items-center">
<div className="flex items-center gap-4">
<a href="/menu" className="p-2"></a>
<h1 className="text-xl font-bold">Growth 📈</h1>
</div>
<div className="flex gap-2">
<button onClick={exportCSV} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Export CSV">
📥
</button>
<button onClick={() => setShowGoals(!showGoals)} className="p-2 text-sm bg-gray-200 dark:bg-gray-700 rounded-lg" title="Set goals">
🎯
</button>
<button onClick={() => { setShowAdd(!showAdd); setEditingId(null); resetForm(); }} className="p-2 bg-rose-400 text-white rounded-lg">
+ Add
</button>
</div>
</div>
{/* Goals Card */}
{showGoals && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
<h3 className="font-semibold mb-3">Set Growth Goals</h3>
<div className="space-y-3">
<div>
<label className="text-sm text-gray-500">Target Weight (kg)</label>
<input
type="number"
step="0.1"
placeholder="e.g., 10"
value={goal.weightKg || ""}
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
className="w-full p-2 border rounded-lg dark:bg-gray-700"
/>
</div>
<div>
<label className="text-sm text-gray-500">Target Height (cm)</label>
<input
type="number"
step="0.1"
placeholder="e.g., 80"
value={goal.heightCm || ""}
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
className="w-full p-2 border rounded-lg dark:bg-gray-700"
/>
</div>
<div className="flex gap-2">
<button onClick={saveGoal} className="flex-1 p-2 bg-rose-400 text-white rounded-lg">
Save Goal
</button>
<button onClick={() => setShowGoals(false)} className="flex-1 p-2 bg-gray-200 dark:bg-gray-700 rounded-lg">
Cancel
</button>
</div>
</div>
</div>
)}
{/* WHO Standards Card - Enhanced with age-wise targets */}
{child && standard && (
<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">
<div>
<div className="font-semibold text-lg">{child.name}</div>
<div className="text-sm text-gray-500">{ageMonths} months old</div>
</div>
{savedGoals.weightKg && (
<div className="text-right text-sm">
<div className="text-gray-500">Goal</div>
<div className="font-medium text-rose-500">{savedGoals.weightKg}kg</div>
</div>
)}
</div>
{/* Color-coded percentile zones legend */}
<div className="flex gap-2 mb-3 text-xs">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-green-500"></span>
Normal (15th-85th)
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-amber-500"></span>
Watch (&lt;15th or &gt;85th)
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-red-500"></span>
Alert (&lt;3rd or &gt;97th)
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div className={`p-2 rounded-lg ${latest?.weight_kg && latest.weight_kg < standard.weight.p3 ? "bg-red-100 dark:bg-red-900" : latest?.weight_kg && latest.weight_kg > standard.weight.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1">Weight</div>
<div className="font-medium text-lg">{standard.weight.p50} kg</div>
<div className="text-xs text-gray-400">Target: {standard.weight.p3}-{standard.weight.p97}</div>
{latest?.weight_kg && (
<div className={`font-medium mt-1 ${getPercentileColor(weightPercentile)}`}>
Actual: {latest.weight_kg}kg ({weightPercentile})
</div>
)}
</div>
<div className={`p-2 rounded-lg ${latest?.height_cm && latest.height_cm < standard.height.p3 ? "bg-red-100 dark:bg-red-900" : latest?.height_cm && latest.height_cm > standard.height.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1">Height</div>
<div className="font-medium text-lg">{standard.height.p50} cm</div>
<div className="text-xs text-gray-400">Target: {standard.height.p3}-{standard.height.p97}</div>
{latest?.height_cm && (
<div className={`font-medium mt-1 ${getPercentileColor(heightPercentile)}`}>
Actual: {latest.height_cm}cm ({heightPercentile})
</div>
)}
</div>
<div className={`p-2 rounded-lg ${latest?.head_circumference_cm && latest.head_circumference_cm < standard.headCircumference.p3 ? "bg-red-100 dark:bg-red-900" : latest?.head_circumference_cm && latest.head_circumference_cm > standard.headCircumference.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1">Head</div>
<div className="font-medium text-lg">{standard.headCircumference.p50} cm</div>
<div className="text-xs text-gray-400">Target: {standard.headCircumference.p3}-{standard.headCircumference.p97}</div>
{latest?.head_circumference_cm && (
<div className={`font-medium mt-1 ${getPercentileColor(headPercentile)}`}>
Actual: {latest.head_circumference_cm}cm ({headPercentile})
</div>
)}
</div>
</div>
{/* Growth velocity indicator */}
{velocity && (
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Growth velocity:</span>
<span className={`font-medium ${velocity.direction === "up" ? "text-green-600" : "text-amber-600"}`}>
{velocity.weight} kg/month {velocity.direction === "up" ? "↑" : "↓"}
</span>
</div>
</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-3 py-1 text-xs rounded ${chartMetric === "weight" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
>
Weight
</button>
<button
onClick={() => setChartMetric("height")}
className={`px-3 py-1 text-xs rounded ${chartMetric === "height" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
>
Height
</button>
<button
onClick={() => setChartMetric("head")}
className={`px-3 py-1 text-xs rounded ${chartMetric === "head" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
>
Head
</button>
</div>
</div>
<Line data={chartData} options={chartOptions} />
</div>
)}
{/* Latest Reading Card */}
{latest && (
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">
<div className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Latest: {new Date(latest.measured_at).toLocaleDateString()}
{velocity && (
<span className="ml-2 text-green-600">
({velocity.weight}kg/mo {velocity.direction === "up" ? "↑" : "↓"})
</span>
)}
</div>
<div className="flex gap-4">
{latest.weight_kg && (
<div>
<div className="text-gray-500 text-xs">Weight</div>
<div className="text-xl font-bold">{latest.weight_kg} kg</div>
{weightPercentile && (
<div className={`text-xs font-medium ${getPercentileColor(weightPercentile)}`}>
{weightPercentile} percentile
{savedGoals.weightKg && (
<span className="ml-1">
{((latest.weight_kg / savedGoals.weightKg) * 100).toFixed(0)}% of goal
</span>
)}
</div>
)}
</div>
)}
{latest.height_cm && (
<div>
<div className="text-gray-500 text-xs">Height</div>
<div className="text-xl font-bold">{latest.height_cm} cm</div>
{heightPercentile && (
<div className={`text-xs font-medium ${getPercentileColor(heightPercentile)}`}>
{heightPercentile} percentile
{savedGoals.heightCm && (
<span className="ml-1">
{((latest.height_cm / savedGoals.heightCm) * 100).toFixed(0)}% of goal
</span>
)}
</div>
)}
</div>
)}
{latest.head_circumference_cm && (
<div>
<div className="text-gray-500 text-xs">Head</div>
<div className="text-xl font-bold">{latest.head_circumference_cm} cm</div>
{headPercentile && (
<div className={`text-xs font-medium ${getPercentileColor(headPercentile)}`}>
{headPercentile} percentile
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Add/Edit Form */}
{showAdd && (
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<h3 className="font-semibold">{editingId ? "Edit Record" : "Add New Measurement"}</h3>
<input
type="date"
value={measuredAt}
onChange={e => setMeasuredAt(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.01"
placeholder="Weight (kg)"
value={weight}
onChange={e => setWeight(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.1"
placeholder="Height (cm)"
value={height}
onChange={e => setHeight(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<input
type="number"
step="0.1"
placeholder="Head circumference (cm)"
value={headCircumference}
onChange={e => setHeadCircumference(e.target.value)}
className="w-full p-3 border rounded-xl dark:bg-gray-700"
/>
<div className="flex gap-2">
{editingId ? (
<>
<button onClick={() => handleEdit(editingId)} className="flex-1 p-3 bg-rose-400 text-white rounded-xl">
Update
</button>
<button onClick={resetForm} className="flex-1 p-3 bg-gray-200 dark:bg-gray-700 rounded-xl">
Cancel
</button>
</>
) : (
<button onClick={handleAdd} className="w-full p-3 bg-rose-400 text-white rounded-xl">
Save
</button>
)}
</div>
</div>
)}
{/* Empty State */}
{loading ? (
<div className="p-8 text-center text-gray-500">
<p>Loading...</p>
</div>
) : growthData.length === 0 ? (
<div className="mx-4 p-8 bg-white dark:bg-gray-800 rounded-xl text-center">
<div className="text-4xl mb-4">📏</div>
<h3 className="text-lg font-semibold mb-2">Track {child?.name}'s Growth</h3>
<p className="text-gray-500 mb-4">
Start logging weight, height, and head measurements to see how {child?.name} is growing compared to WHO standards.
</p>
<button onClick={() => setShowAdd(true)} className="p-3 bg-rose-400 text-white rounded-xl">
Add First Measurement
</button>
</div>
) : (
/* History List */
<div className="px-4 space-y-2">
<h3 className="font-semibold mb-2">History</h3>
{growthData.map((record: any, i: number) => (
<div key={i} className="p-4 bg-white dark:bg-gray-800 rounded-xl flex justify-between items-center">
<div>
<div className="text-sm text-gray-500">
{new Date(record.measured_at).toLocaleDateString()}
</div>
<div className="flex gap-4 mt-1">
{record.weight_kg && (
<div> {record.weight_kg} kg</div>
)}
{record.height_cm && (
<div>📏 {record.height_cm} cm</div>
)}
{record.head_circumference_cm && (
<div> {record.head_circumference_cm} cm</div>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(record)}
className="p-2 text-sm text-gray-500 hover:text-rose-500"
title="Edit"
>
</button>
<button
onClick={() => handleDelete(record.id)}
className="p-2 text-sm text-gray-500 hover:text-red-500"
title="Delete"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}