From 318b277e44fe5796918e85eb8d9f4af81e7039d1 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 17 May 2026 13:52:02 +0530 Subject: [PATCH] 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 --- CLAUDE.md | 6 +- src/app/api/growth/route.ts | 75 ++++++ src/app/growth/page.tsx | 467 ++++++++++++++++++++++++++++++------ 3 files changed, 474 insertions(+), 74 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8e37b3..3c2ce5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,7 +124,7 @@ if (!ownership.success) { **AI Integration:** -- Route: `/api/ai` → LiteLLM at `https://llm.manohargupta.com` +- Route: `/api/ai` → LiteLLM (set via `LITELLM_BASE_URL` env var) - Model: `minimax-2.7` - See `/docs/debugging.md` for troubleshooting @@ -235,8 +235,8 @@ Required: - `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1) - `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only) -- `LITELLM_URL` - AI gateway URL -- `LITELLM_KEY` - AI API key +- `LITELLM_BASE_URL` - AI gateway URL (e.g., https://llm.manohargupta.com) +- `LITELLM_API_KEY` - AI API key - `R2_ACCOUNT_ID` - Cloudflare R2 account ID - `R2_ACCESS_KEY_ID` - R2 access key - `R2_SECRET_ACCESS_KEY` - R2 secret key diff --git a/src/app/api/growth/route.ts b/src/app/api/growth/route.ts index f0c6f07..49f3570 100644 --- a/src/app/api/growth/route.ts +++ b/src/app/api/growth/route.ts @@ -89,4 +89,79 @@ export async function GET(request: Request) { console.error(error); return NextResponse.json({ error: String(error) }, { status: 500 }); } +} + +export async function PUT(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + try { + const body = await request.json(); + const { id, measuredAt, weightKg, heightCm, headCircumferenceCm, notes } = body; + + if (!id) { + return NextResponse.json({ error: "id required" }, { status: 400 }); + } + + // Verify the growth record belongs to a child in user's family + const existing = await sql.unsafe( + `SELECT g.child_id FROM growth g + JOIN children c ON g.child_id = c.id + JOIN family_members fm ON c.family_id = fm.family_id + WHERE g.id = $1 AND fm.user_id = (SELECT user_id FROM sessions WHERE session_token = $2 LIMIT 1)`, + [id, request.headers.get("cookie")?.match(/tia_session=([^;]+)/)?.[1]] + ); + + if (!existing || existing.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await sql.unsafe( + `UPDATE growth SET measured_at = $1, weight_kg = $2, height_cm = $3, head_circumference_cm = $4, notes = $5 WHERE id = $6`, + [measuredAt ? new Date(measuredAt) : null, weightKg ?? null, heightCm ?? null, headCircumferenceCm ?? null, notes ?? null, id] + ); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + const auth = await requireFamily(); + if (!auth.success) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "id required" }, { status: 400 }); + } + + try { + // Verify the growth record belongs to a child in user's family + const sessionToken = request.headers.get("cookie")?.match(/tia_session=([^;]+)/)?.[1] || ""; + const existing = await sql.unsafe( + `SELECT g.child_id FROM growth g + JOIN children c ON g.child_id = c.id + JOIN family_members fm ON c.family_id = fm.family_id + WHERE g.id = $1 AND fm.user_id = (SELECT user_id FROM sessions WHERE session_token = $2 LIMIT 1)`, + [id, sessionToken] + ); + + if (!existing || existing.length === 0) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + await sql.unsafe(`DELETE FROM growth WHERE id = $1`, [id]); + return NextResponse.json({ success: true }); + } catch (error) { + console.error(error); + return NextResponse.json({ error: String(error) }, { status: 500 }); + } } \ No newline at end of file diff --git a/src/app/growth/page.tsx b/src/app/growth/page.tsx index 5bded81..684aefc 100644 --- a/src/app/growth/page.tsx +++ b/src/app/growth/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useFamily } from "../FamilyProvider"; -import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile } from "@/lib/growth-standards"; +import { WHO_BOY_WEIGHT, WHO_GIRL_WEIGHT, getAgeInMonthsFromBirth, getPercentile, type GrowthStandard } from "@/lib/growth-standards"; import { Chart as ChartJS, CategoryScale, @@ -15,6 +15,7 @@ import { Filler, } from "chart.js"; import { Line } from "react-chartjs-2"; +import { useTheme } from "../ThemeProvider"; ChartJS.register( CategoryScale, @@ -27,18 +28,67 @@ ChartJS.register( 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 [growthData, setGrowthData] = useState([]); + const { theme } = useTheme(); + const isDark = theme === "dark"; + + const [growthData, setGrowthData] = useState([]); const [whoStandard, setWhoStandard] = useState(null); const [loading, setLoading] = useState(true); const [showAdd, setShowAdd] = useState(false); + const [showGoals, setShowGoals] = useState(false); + const [editingId, setEditingId] = useState(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({}); + + // Saved goals from localStorage + const [savedGoals, setSavedGoals] = useState({}); + 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()) @@ -48,7 +98,7 @@ export default function GrowthPage() { setLoading(false); }) .catch(() => setLoading(false)); - }, [childId]); + }; const handleAdd = async () => { if (!childId || (!weight && !height && !headCircumference)) return; @@ -57,23 +107,83 @@ export default function GrowthPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ childId, - measuredAt: new Date().toISOString(), + 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(""); - // Refresh data - fetch(`/api/growth?childId=${childId}`) - .then(r => r.json()) - .then(data => { - setGrowthData(data.growth || []); - setWhoStandard(data.whoStandard); - }); + 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 @@ -84,6 +194,14 @@ export default function GrowthPage() { 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; @@ -92,6 +210,20 @@ export default function GrowthPage() { const headPercentile = latest?.head_circumference_cm && whoStandard ? getPercentile(latest.head_circumference_cm, whoStandard.headCircumference.p3, whoStandard.headCircumference.p15, whoStandard.headCircumference.p50, whoStandard.headCircumference.p85, whoStandard.headCircumference.p97) : null; + // 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; @@ -103,7 +235,6 @@ export default function GrowthPage() { const sorted = [...growthData].reverse(); const labels = sorted.map(r => { - const age = getAgeInMonthsFromBirth(child.birthDate); const measured = new Date(r.measured_at); const birth = new Date(child.birthDate); const months = Math.floor((measured.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 30)); @@ -122,17 +253,42 @@ export default function GrowthPage() { backgroundColor: "rgba(251, 113, 133, 0.1)", fill: true, tension: 0.3, + pointRadius: 6, + pointBackgroundColor: "#fb7185", }, + // WHO 85th percentile { - label: "WHO 50th", + 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: "WHO 3rd-97th", + 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)", @@ -146,11 +302,13 @@ export default function GrowthPage() { const chartOptions = { responsive: true, plugins: { - legend: { display: true, position: "top" as const }, + legend: { display: true, position: "top" as const, labels: { boxWidth: 10 } }, title: { display: false }, }, scales: { - y: { title: { display: true, text: chartMetric === "weight" ? "Weight (kg)" : "cm" } }, + y: { + title: { display: true, text: chartMetric === "weight" ? "Weight (kg)" : "cm" }, + }, }, }; @@ -159,41 +317,140 @@ export default function GrowthPage() { } return ( -
+

Growth 📈

- +
+ + + +
- {/* Benchmark Card */} + {/* Goals Card */} + {showGoals && ( +
+

Set Growth Goals

+
+
+ + setGoal({ ...goal, weightKg: parseFloat(e.target.value) || undefined })} + className="w-full p-2 border rounded-lg dark:bg-gray-700" + /> +
+
+ + setGoal({ ...goal, heightCm: parseFloat(e.target.value) || undefined })} + className="w-full p-2 border rounded-lg dark:bg-gray-700" + /> +
+
+ + +
+
+
+ )} + + {/* WHO Standards Card - Enhanced with age-wise targets */} {child && standard && (
-
-
{child.name}
-
{ageMonths} months old
+
+
+
{child.name}
+
{ageMonths} months old
+
+ {savedGoals.weightKg && ( +
+
Goal
+
{savedGoals.weightKg}kg
+
+ )}
+ + {/* Color-coded percentile zones legend */} +
+ + + Normal (15th-85th) + + + + Watch (<15th or >85th) + + + + Alert (<3rd or >97th) + +
+
-
-
Weight (50th %)
+
standard.weight.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}> +
Weight
{standard.weight.p50} kg
-
Range: {standard.weight.p3}-{standard.weight.p97}
+
Target: {standard.weight.p3}-{standard.weight.p97}
+ {latest?.weight_kg && ( +
+ Actual: {latest.weight_kg}kg ({weightPercentile}) +
+ )}
-
-
Height (50th %)
+
standard.height.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}> +
Height
{standard.height.p50} cm
-
Range: {standard.height.p3}-{standard.height.p97}
+
Target: {standard.height.p3}-{standard.height.p97}
+ {latest?.height_cm && ( +
+ Actual: {latest.height_cm}cm ({heightPercentile}) +
+ )}
-
-
Head (50th %)
+
standard.headCircumference.p85 ? "bg-amber-100 dark:bg-amber-900" : "bg-green-100 dark:bg-green-900"}`}> +
Head
{standard.headCircumference.p50} cm
-
Range: {standard.headCircumference.p3}-{standard.headCircumference.p97}
+
Target: {standard.headCircumference.p3}-{standard.headCircumference.p97}
+ {latest?.head_circumference_cm && ( +
+ Actual: {latest.head_circumference_cm}cm ({headPercentile}) +
+ )}
+ + {/* Growth velocity indicator */} + {velocity && ( +
+
+ Growth velocity: + + {velocity.weight} kg/month {velocity.direction === "up" ? "↑" : "↓"} + +
+
+ )}
)} @@ -205,19 +462,19 @@ export default function GrowthPage() {
@@ -227,11 +484,16 @@ export default function GrowthPage() {
)} - {/* Latest Reading with Percentile */} + {/* Latest Reading Card */} {latest && (
-
+
Latest: {new Date(latest.measured_at).toLocaleDateString()} + {velocity && ( + + ({velocity.weight}kg/mo {velocity.direction === "up" ? "↑" : "↓"}) + + )}
{latest.weight_kg && ( @@ -239,8 +501,13 @@ export default function GrowthPage() {
Weight
{latest.weight_kg} kg
{weightPercentile && ( -
+
{weightPercentile} percentile + {savedGoals.weightKg && ( + + → {((latest.weight_kg / savedGoals.weightKg) * 100).toFixed(0)}% of goal + + )}
)}
@@ -250,8 +517,13 @@ export default function GrowthPage() {
Height
{latest.height_cm} cm
{heightPercentile && ( -
+
{heightPercentile} percentile + {savedGoals.heightCm && ( + + → {((latest.height_cm / savedGoals.heightCm) * 100).toFixed(0)}% of goal + + )}
)}
@@ -261,7 +533,7 @@ export default function GrowthPage() {
Head
{latest.head_circumference_cm} cm
{headPercentile && ( -
+
{headPercentile} percentile
)} @@ -271,15 +543,23 @@ export default function GrowthPage() {
)} + {/* Add/Edit Form */} {showAdd && ( -
+
+

{editingId ? "Edit Record" : "Add New Measurement"}

+ setMeasuredAt(e.target.value)} + className="w-full p-3 border rounded-xl dark:bg-gray-700" + /> setWeight(e.target.value)} - className="w-full p-3 border rounded-xl" + className="w-full p-3 border rounded-xl dark:bg-gray-700" /> setHeight(e.target.value)} - className="w-full p-3 border rounded-xl" + className="w-full p-3 border rounded-xl dark:bg-gray-700" /> setHeadCircumference(e.target.value)} - className="w-full p-3 border rounded-xl" + className="w-full p-3 border rounded-xl dark:bg-gray-700" /> - +
+ {editingId ? ( + <> + + + + ) : ( + + )} +
)} -
- {loading ? ( -

Loading...

- ) : growthData.length === 0 ? ( -

No growth records yet

- ) : ( - growthData.map((record: any, i: number) => ( -
-
- {new Date(record.measured_at).toLocaleDateString()} + {/* Empty State */} + {loading ? ( +
+

Loading...

+
+ ) : growthData.length === 0 ? ( +
+
📏
+

Track {child?.name}'s Growth

+

+ Start logging weight, height, and head measurements to see how {child?.name} is growing compared to WHO standards. +

+ +
+ ) : ( + /* History List */ +
+

History

+ {growthData.map((record: any, i: number) => ( +
+
+
+ {new Date(record.measured_at).toLocaleDateString()} +
+
+ {record.weight_kg && ( +
⚖️ {record.weight_kg} kg
+ )} + {record.height_cm && ( +
📏 {record.height_cm} cm
+ )} + {record.head_circumference_cm && ( +
⭕ {record.head_circumference_cm} cm
+ )} +
-
- {record.weight_kg && ( -
⚖️ {record.weight_kg} kg
- )} - {record.height_cm && ( -
📏 {record.height_cm} cm
- )} - {record.head_circumference_cm && ( -
⭕ {record.head_circumference_cm} cm
- )} +
+ +
- )) - )} -
+ ))} +
+ )}
); } \ No newline at end of file