tia/src/app/(app)/growth/page.tsx
Mannu deaa1810d7 feat: add Umami self-hosted analytics with custom event tracking
- Root layout: load Umami script (afterInteractive) — covers all pages including
  SPA navigation auto-tracking
- Marketing layout: remove Plausible script (Umami now covers marketing pages too)
- src/lib/analytics.ts: type-safe track() wrapper + typed helpers for each event;
  window.umami declared globally; safe no-op on SSR/ad-block
- Custom events wired:
    log-created { logType }  — LogModal on successful save
    garment-added            — wardrobe/add after save
    memory-added             — memories after upload pipeline completes
    growth-logged            — growth page after measurement saved
    pwa-installed            — InstallPrompt when Android prompt accepted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 00:40:05 +05:30

691 lines
No EOL
30 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 } from "react";
import Link from "next/link";
import { useFamily } from "@/app/FamilyProvider";
import { Button, Card, Input, ConfirmDialog } from "@/components/ui";
import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards";
import { formatAge } from "@/lib/formatting";
import { fmtDate } from "@/lib/date-ist";
import { trackGrowthLogged } from "@/lib/analytics";
import type { GrowthRecord, Goal } from "@/types";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";
import { Line } from "react-chartjs-2";
import { useTheme } from "@/app/ThemeProvider";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
interface Percentiles { p3: number; p15: number; p50: number; p85: number; p97: number }
interface WhoStandard {
weight: Percentiles;
height: Percentiles;
headCircumference: Percentiles;
}
export default function GrowthPage() {
const { childId, child, familyId } = useFamily();
useTheme(); // keep theme context alive for dark mode CSS
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
const [whoStandard, setWhoStandard] = useState<WhoStandard | null>(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");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [showWhoStandards, setShowWhoStandards] = useState(true);
const [showChart, setShowChart] = useState(true);
const [showHistory, setShowHistory] = useState(true);
// 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)) {
setSaveError("Please enter at least one measurement");
return;
}
setSaving(true);
setSaveError(null);
try {
const res = 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,
}),
});
if (!res.ok) {
const err = await res.json();
setSaveError(err.error || "Failed to save");
return;
}
trackGrowthLogged();
resetForm();
fetchGrowthData();
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
};
const handleEdit = async (id: string) => {
setSaving(true);
setSaveError(null);
try {
const res = 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,
}),
});
if (!res.ok) {
const err = await res.json();
setSaveError(err.error || "Failed to update");
return;
}
setEditingId(null);
resetForm();
fetchGrowthData();
} catch (e: any) {
setSaveError(e.message || "Failed to update");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
await fetch(`/api/growth?id=${id}`, { method: "DELETE" });
setConfirmDeleteId(null);
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 => [
fmtDate(r.measured_at),
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 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 50th percentile
{
label: "50th",
data: labels.map(() => whoStandard?.[whoKey]?.p50 ?? null),
borderColor: "#22c55e",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
// WHO 15th percentile
{
label: "15th",
data: labels.map(() => whoStandard?.[whoKey]?.p15 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
{
label: "3rd",
data: labels.map(() => whoStandard?.[whoKey]?.p3 ?? null),
borderColor: "#fbbf24",
borderDash: [5, 5],
fill: false,
pointRadius: 0,
tension: 0.4,
},
],
};
})();
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">
{/* Header */}
<div className="sticky top-0 z-10 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-100 dark:border-gray-800 px-4 py-3 flex justify-between items-center">
<div className="flex items-center gap-3">
<Link href="/menu" className="text-gray-500 dark:text-gray-400 p-1"></Link>
<h1 className="text-sm font-semibold dark:text-white">Growth 📈</h1>
</div>
<div className="flex items-center gap-2">
<button
title="Set goals"
onClick={() => { setShowGoals(g => !g); setShowAdd(false); setEditingId(null); }}
className={`p-2 rounded-lg text-sm transition-colors ${showGoals ? "bg-amber-100 dark:bg-amber-900 text-amber-600" : "text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700"}`}
>
🎯
</button>
<Button
variant="primary"
size="sm"
onClick={() => {
setShowAdd(a => !a);
setShowGoals(false);
setEditingId(null);
setWeight(""); setHeight(""); setHeadCircumference("");
setMeasuredAt(new Date().toISOString().split("T")[0]);
}}
>
+ Add
</Button>
</div>
</div>
{/* Goals Card */}
{showGoals && (
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<h3 className="font-semibold text-sm dark:text-white">🎯 Growth Goals</h3>
<Input label="Target Weight (kg)" type="number" step="0.1" placeholder="e.g., 10"
value={goal.weightKg || ""}
onChange={e => setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })}
/>
<Input label="Target Height (cm)" type="number" step="0.1" placeholder="e.g., 80"
value={goal.heightCm || ""}
onChange={e => setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })}
/>
<div className="flex gap-2">
<Button fullWidth onClick={saveGoal}>Save Goal</Button>
<Button variant="secondary" fullWidth onClick={() => setShowGoals(false)}>Cancel</Button>
</div>
</div>
)}
{/* Add / Edit Measurement — always at the top, right below the header */}
{showAdd && (
<div className="mx-4 mt-4 mb-2 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
<h3 className="font-semibold text-sm dark:text-white">{editingId ? "✏️ Edit Record" : "📏 New Measurement"}</h3>
{saveError && (
<div className="p-2 bg-red-50 dark:bg-red-900/40 text-red-600 dark:text-red-300 rounded-lg text-xs">{saveError}</div>
)}
<Input type="date" value={measuredAt} onChange={e => setMeasuredAt(e.target.value)} />
<div className="grid grid-cols-3 gap-2">
<Input type="number" step="0.01" placeholder="Weight kg" value={weight} onChange={e => setWeight(e.target.value)} />
<Input type="number" step="0.1" placeholder="Height cm" value={height} onChange={e => setHeight(e.target.value)} />
<Input type="number" step="0.1" placeholder="Head cm" value={headCircumference} onChange={e => setHeadCircumference(e.target.value)} />
</div>
<div className="flex gap-2">
{editingId ? (
<>
<Button fullWidth loading={saving} onClick={() => handleEdit(editingId)}>Update</Button>
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
</>
) : (
<>
<Button fullWidth loading={saving} onClick={() => handleAdd()}>Save</Button>
<Button variant="secondary" fullWidth onClick={resetForm}>Cancel</Button>
</>
)}
</div>
</div>
)}
{/* Latest Reading Card */}
{latest && (
<div className="mx-4 mt-4 mb-2 p-4 bg-gradient-to-r from-rose-50 to-pink-50 dark:from-rose-900/40 dark:to-pink-900/40 rounded-2xl shadow-sm">
<div className="flex justify-between items-center mb-3">
<div>
<div className="font-semibold text-rose-600 dark:text-rose-300">Latest Reading</div>
<div className="text-sm text-gray-500">
{fmtDate(latest.measured_at)} ({child ? formatAge(child.birthDate, latest.measured_at) : ""})
</div>
</div>
{velocity && (
<div className={`text-sm font-medium ${velocity.direction === "up" ? "text-green-500" : "text-amber-500"}`}>
{velocity.weight}kg/mo {velocity.direction === "up" ? "↑" : "↓"}
</div>
)}
</div>
<div className="grid grid-cols-3 gap-3">
{latest.weight_kg && (
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg hover:shadow-md transition-shadow">
<div className="text-xs text-gray-500 mb-1">Weight</div>
<div className="text-xl font-bold">{latest.weight_kg} <span className="text-sm font-normal">kg</span></div>
{weightPercentile && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(weightPercentile)}`}>
{weightPercentile}
{savedGoals.weightKg && (
<span className="ml-1 text-gray-400">
{((latest.weight_kg / savedGoals.weightKg) * 100).toFixed(0)}%
</span>
)}
</div>
)}
</div>
)}
{latest.height_cm && (
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg hover:shadow-md transition-shadow">
<div className="text-xs text-gray-500 mb-1">Height</div>
<div className="text-xl font-bold">{latest.height_cm} <span className="text-sm font-normal">cm</span></div>
{heightPercentile && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(heightPercentile)}`}>
{heightPercentile}
{savedGoals.heightCm && (
<span className="ml-1 text-gray-400">
{((latest.height_cm / savedGoals.heightCm) * 100).toFixed(0)}%
</span>
)}
</div>
)}
</div>
)}
{latest.head_circumference_cm && (
<div className="p-3 bg-white dark:bg-gray-800 rounded-lg hover:shadow-md transition-shadow">
<div className="text-xs text-gray-500 mb-1">Head</div>
<div className="text-xl font-bold">{latest.head_circumference_cm} <span className="text-sm font-normal">cm</span></div>
{headPercentile && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(headPercentile)}`}>
{headPercentile}
</div>
)}
</div>
)}
</div>
</div>
)}
{/* WHO Standards + Chart Card - Collapsible */}
{child && standard && (
<div className="mx-4 mt-4 mb-2 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
{/* WHO Standards Header - Clickable */}
<button
onClick={() => setShowWhoStandards(!showWhoStandards)}
className="w-full p-4 flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="text-left">
<div className="font-semibold text-lg">{child.name}</div>
<div className="text-sm text-gray-500">{formatAge(child.birthDate)}</div>
</div>
<div className="flex items-center gap-2">
{savedGoals.weightKg && (
<div className="text-sm text-rose-500 mr-2">Goal: {savedGoals.weightKg}kg</div>
)}
<span className={`transform transition-transform ${showWhoStandards ? "rotate-180" : ""}`}></span>
</div>
</button>
{/* When collapsed - Show recommended values with icons */}
{!showWhoStandards && (
<div className="px-4 pb-4">
<div className="grid grid-cols-3 gap-3">
<div className={`p-3 rounded-lg text-center ${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-50 dark:bg-green-900"}`}>
<div className="text-2xl mb-1"></div>
<div className="font-medium">{standard.weight.p50} kg</div>
<div className="text-xs text-gray-500">{standard.weight.p3}-{standard.weight.p97}</div>
</div>
<div className={`p-3 rounded-lg text-center ${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-50 dark:bg-green-900"}`}>
<div className="text-2xl mb-1">📏</div>
<div className="font-medium">{standard.height.p50} cm</div>
<div className="text-xs text-gray-500">{standard.height.p3}-{standard.height.p97}</div>
</div>
<div className={`p-3 rounded-lg text-center ${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-50 dark:bg-green-900"}`}>
<div className="text-2xl mb-1"></div>
<div className="font-medium">{standard.headCircumference.p50} cm</div>
<div className="text-xs text-gray-500">{standard.headCircumference.p3}-{standard.headCircumference.p97}</div>
</div>
</div>
</div>
)}
{/* When expanded - Show full details + chart */}
{showWhoStandards && (
<div className="px-4 pb-4">
{/* Color-coded percentile zones legend */}
<div className="flex gap-3 mb-3 text-xs">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-green-500"></span>
Normal
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-amber-500"></span>
Watch
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-red-500"></span>
Alert
</span>
</div>
{/* WHO Standards Cards */}
<div className="grid grid-cols-3 gap-3 mb-3">
<div className={`p-3 rounded-lg hover:shadow-md transition-shadow ${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-50 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1 text-xs">Weight</div>
<div className="font-medium text-lg">{standard.weight.p50} kg</div>
<div className="text-xs text-gray-400">{standard.weight.p3}-{standard.weight.p97}</div>
{latest?.weight_kg && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(weightPercentile)}`}>
{latest.weight_kg}kg ({weightPercentile})
</div>
)}
</div>
<div className={`p-3 rounded-lg hover:shadow-md transition-shadow ${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-50 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1 text-xs">Height</div>
<div className="font-medium text-lg">{standard.height.p50} cm</div>
<div className="text-xs text-gray-400">{standard.height.p3}-{standard.height.p97}</div>
{latest?.height_cm && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(heightPercentile)}`}>
{latest.height_cm}cm ({heightPercentile})
</div>
)}
</div>
<div className={`p-3 rounded-lg hover:shadow-md transition-shadow ${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-50 dark:bg-green-900"}`}>
<div className="text-gray-500 mb-1 text-xs">Head</div>
<div className="font-medium text-lg">{standard.headCircumference.p50} cm</div>
<div className="text-xs text-gray-400">{standard.headCircumference.p3}-{standard.headCircumference.p97}</div>
{latest?.head_circumference_cm && (
<div className={`text-xs font-medium mt-1 ${getPercentileColor(headPercentile)}`}>
{latest.head_circumference_cm}cm ({headPercentile})
</div>
)}
</div>
</div>
{/* Growth velocity indicator */}
{velocity && (
<div className="flex items-center gap-2 text-sm mb-3">
<span className="text-gray-500">Velocity:</span>
<span className={`font-medium ${velocity.direction === "up" ? "text-green-600" : "text-amber-600"}`}>
{velocity.weight} kg/month {velocity.direction === "up" ? "↑" : "↓"}
</span>
</div>
)}
{/* Growth Chart - Collapsible inside expanded view */}
{chartData && growthData.length > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 pt-3">
<button
onClick={() => setShowChart(!showChart)}
className="w-full flex justify-between items-center mb-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded p-2 -mx-2 transition-colors"
>
<h3 className="font-semibold text-sm">📈 Growth Chart</h3>
<span className={`transform transition-transform ${showChart ? "rotate-180" : ""}`}></span>
</button>
{showChart && <Line data={chartData} options={chartOptions} />}
</div>
)}
</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 mt-4 mb-4 p-6 bg-white dark:bg-gray-800 rounded-2xl shadow-sm text-center">
<div className="text-4xl mb-3">📏</div>
<h3 className="text-base font-semibold mb-1 dark:text-white">Track {child?.name}&apos;s Growth</h3>
<p className="text-gray-500 dark:text-gray-400 mb-4 text-sm">
Log weight, height, and head measurements to see how {child?.name} compares to WHO standards.
</p>
<Button onClick={() => { setShowAdd(true); setShowGoals(false); window.scrollTo({ top: 0, behavior: "smooth" }); }}>
Add First Measurement
</Button>
</div>
) : (
/* History List - Collapsible */
<div className="mx-4 mt-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
<button
onClick={() => setShowHistory(!showHistory)}
className="w-full p-4 flex justify-between items-center hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="font-semibold">History ({growthData.length} records)</div>
<span className={`transform transition-transform ${showHistory ? "rotate-180" : ""}`}></span>
</button>
{showHistory && (
<div className="px-4 pb-4 space-y-2 max-h-96 overflow-y-auto">
{growthData.map((record, i) => (
<div key={i} className="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:shadow-md transition-shadow flex justify-between items-center">
<div>
<div className="text-sm text-gray-500">
{fmtDate(record.measured_at)} ({child ? formatAge(child.birthDate, record.measured_at) : ""})
</div>
<div className="flex gap-2 mt-1">
{record.weight_kg && (
<span className="px-2 py-0.5 bg-white dark:bg-gray-600 rounded text-xs"> {record.weight_kg}kg</span>
)}
{record.height_cm && (
<span className="px-2 py-0.5 bg-white dark:bg-gray-600 rounded text-xs">📏 {record.height_cm}cm</span>
)}
{record.head_circumference_cm && (
<span className="px-2 py-0.5 bg-white dark:bg-gray-600 rounded text-xs"> {record.head_circumference_cm}cm</span>
)}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(record)}
className="p-2 text-sm text-gray-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-900 rounded-lg transition-colors"
title="Edit"
>
</button>
<button
onClick={() => setConfirmDeleteId(record.id)}
className="p-2 text-sm text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900 rounded-lg transition-colors"
title="Delete"
>
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
<ConfirmDialog
open={!!confirmDeleteId}
onClose={() => setConfirmDeleteId(null)}
onConfirm={() => confirmDeleteId && handleDelete(confirmDeleteId)}
title="Delete this record?"
description="This measurement will be permanently removed."
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}