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>
This commit is contained in:
parent
1628588d5a
commit
318b277e44
3 changed files with 474 additions and 74 deletions
|
|
@ -124,7 +124,7 @@ if (!ownership.success) {
|
||||||
|
|
||||||
**AI Integration:**
|
**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`
|
- Model: `minimax-2.7`
|
||||||
- See `/docs/debugging.md` for troubleshooting
|
- See `/docs/debugging.md` for troubleshooting
|
||||||
|
|
||||||
|
|
@ -235,8 +235,8 @@ Required:
|
||||||
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1)
|
- `DATABASE_URL` - PostgreSQL connection (as `tia_app` role after H2.1)
|
||||||
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
|
- `DATABASE_URL_SUPERUSER` - Superuser connection (for migrations only)
|
||||||
- `LITELLM_URL` - AI gateway URL
|
- `LITELLM_BASE_URL` - AI gateway URL (e.g., https://llm.manohargupta.com)
|
||||||
- `LITELLM_KEY` - AI API key
|
- `LITELLM_API_KEY` - AI API key
|
||||||
- `R2_ACCOUNT_ID` - Cloudflare R2 account ID
|
- `R2_ACCOUNT_ID` - Cloudflare R2 account ID
|
||||||
- `R2_ACCESS_KEY_ID` - R2 access key
|
- `R2_ACCESS_KEY_ID` - R2 access key
|
||||||
- `R2_SECRET_ACCESS_KEY` - R2 secret key
|
- `R2_SECRET_ACCESS_KEY` - R2 secret key
|
||||||
|
|
|
||||||
|
|
@ -90,3 +90,78 @@ export async function GET(request: Request) {
|
||||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useFamily } from "../FamilyProvider";
|
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 {
|
import {
|
||||||
Chart as ChartJS,
|
Chart as ChartJS,
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
Filler,
|
Filler,
|
||||||
} from "chart.js";
|
} from "chart.js";
|
||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
|
import { useTheme } from "../ThemeProvider";
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
|
|
@ -27,18 +28,67 @@ ChartJS.register(
|
||||||
Filler
|
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() {
|
export default function GrowthPage() {
|
||||||
const { childId, child, familyId } = useFamily();
|
const { childId, child, familyId } = useFamily();
|
||||||
const [growthData, setGrowthData] = useState<any[]>([]);
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
const [growthData, setGrowthData] = useState<GrowthRecord[]>([]);
|
||||||
const [whoStandard, setWhoStandard] = useState<any>(null);
|
const [whoStandard, setWhoStandard] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAdd, setShowAdd] = useState(false);
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [showGoals, setShowGoals] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
const [weight, setWeight] = useState("");
|
const [weight, setWeight] = useState("");
|
||||||
const [height, setHeight] = useState("");
|
const [height, setHeight] = useState("");
|
||||||
const [headCircumference, setHeadCircumference] = 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 [chartMetric, setChartMetric] = useState<"weight" | "height" | "head">("weight");
|
||||||
|
|
||||||
|
// Goals state
|
||||||
|
const [goal, setGoal] = useState<Goal>({});
|
||||||
|
|
||||||
|
// Saved goals from localStorage
|
||||||
|
const [savedGoals, setSavedGoals] = useState<Goal>({});
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
if (!childId) return;
|
||||||
fetch(`/api/growth?childId=${childId}`)
|
fetch(`/api/growth?childId=${childId}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|
@ -48,7 +98,7 @@ export default function GrowthPage() {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
})
|
})
|
||||||
.catch(() => setLoading(false));
|
.catch(() => setLoading(false));
|
||||||
}, [childId]);
|
};
|
||||||
|
|
||||||
const handleAdd = async () => {
|
const handleAdd = async () => {
|
||||||
if (!childId || (!weight && !height && !headCircumference)) return;
|
if (!childId || (!weight && !height && !headCircumference)) return;
|
||||||
|
|
@ -57,23 +107,83 @@ export default function GrowthPage() {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
childId,
|
childId,
|
||||||
measuredAt: new Date().toISOString(),
|
measuredAt: new Date(measuredAt).toISOString(),
|
||||||
weightKg: weight ? parseFloat(weight) : null,
|
weightKg: weight ? parseFloat(weight) : null,
|
||||||
heightCm: height ? parseFloat(height) : null,
|
heightCm: height ? parseFloat(height) : null,
|
||||||
headCircumferenceCm: headCircumference ? parseFloat(headCircumference) : 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);
|
setShowAdd(false);
|
||||||
|
setEditingId(null);
|
||||||
setWeight("");
|
setWeight("");
|
||||||
setHeight("");
|
setHeight("");
|
||||||
setHeadCircumference("");
|
setHeadCircumference("");
|
||||||
// Refresh data
|
setMeasuredAt(new Date().toISOString().split("T")[0]);
|
||||||
fetch(`/api/growth?childId=${childId}`)
|
};
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
const startEdit = (record: GrowthRecord) => {
|
||||||
setGrowthData(data.growth || []);
|
setEditingId(record.id);
|
||||||
setWhoStandard(data.whoStandard);
|
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
|
// Get latest measurement
|
||||||
|
|
@ -84,6 +194,14 @@ export default function GrowthPage() {
|
||||||
const standards = child?.sex === "male" ? WHO_BOY_WEIGHT : WHO_GIRL_WEIGHT;
|
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];
|
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
|
// Calculate percentile for latest reading
|
||||||
const weightPercentile = latest?.weight_kg && whoStandard ?
|
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;
|
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 ?
|
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;
|
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
|
// Prepare chart data
|
||||||
const chartData = (() => {
|
const chartData = (() => {
|
||||||
if (growthData.length === 0 || !child?.birthDate) return null;
|
if (growthData.length === 0 || !child?.birthDate) return null;
|
||||||
|
|
@ -103,7 +235,6 @@ export default function GrowthPage() {
|
||||||
const sorted = [...growthData].reverse();
|
const sorted = [...growthData].reverse();
|
||||||
|
|
||||||
const labels = sorted.map(r => {
|
const labels = sorted.map(r => {
|
||||||
const age = getAgeInMonthsFromBirth(child.birthDate);
|
|
||||||
const measured = new Date(r.measured_at);
|
const measured = new Date(r.measured_at);
|
||||||
const birth = new Date(child.birthDate);
|
const birth = new Date(child.birthDate);
|
||||||
const months = Math.floor((measured.getTime() - birth.getTime()) / (1000 * 60 * 60 * 24 * 30));
|
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)",
|
backgroundColor: "rgba(251, 113, 133, 0.1)",
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.3,
|
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),
|
data: labels.map(() => whoStandard?.[whoKey]?.p50),
|
||||||
borderColor: "#22c55e",
|
borderColor: "#22c55e",
|
||||||
borderDash: [5, 5],
|
borderDash: [5, 5],
|
||||||
fill: false,
|
fill: false,
|
||||||
pointRadius: 0,
|
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]),
|
data: labels.map(() => [whoStandard?.[whoKey]?.p3, whoStandard?.[whoKey]?.p97]),
|
||||||
borderColor: "transparent",
|
borderColor: "transparent",
|
||||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||||
|
|
@ -146,11 +302,13 @@ export default function GrowthPage() {
|
||||||
const chartOptions = {
|
const chartOptions = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: { display: true, position: "top" as const },
|
legend: { display: true, position: "top" as const, labels: { boxWidth: 10 } },
|
||||||
title: { display: false },
|
title: { display: false },
|
||||||
},
|
},
|
||||||
scales: {
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
<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="p-4 flex justify-between items-center">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<a href="/menu" className="p-2">←</a>
|
<a href="/menu" className="p-2">←</a>
|
||||||
<h1 className="text-xl font-bold">Growth 📈</h1>
|
<h1 className="text-xl font-bold">Growth 📈</h1>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowAdd(!showAdd)} className="p-2 bg-rose-400 text-white rounded-lg">
|
<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
|
+ Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Benchmark Card */}
|
{/* 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 && (
|
{child && standard && (
|
||||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
|
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<div className="font-medium">{child.name}</div>
|
<div>
|
||||||
|
<div className="font-semibold text-lg">{child.name}</div>
|
||||||
<div className="text-sm text-gray-500">{ageMonths} months old</div>
|
<div className="text-sm text-gray-500">{ageMonths} months old</div>
|
||||||
</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 (<15th or >85th)
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-red-500"></span>
|
||||||
|
Alert (<3rd or >97th)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<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 (50th %)</div>
|
<div className="text-gray-500 mb-1">Weight</div>
|
||||||
<div className="font-medium text-lg">{standard.weight.p50} kg</div>
|
<div className="font-medium text-lg">{standard.weight.p50} kg</div>
|
||||||
<div className="text-xs text-gray-400">Range: {standard.weight.p3}-{standard.weight.p97}</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>
|
)}
|
||||||
<div className="text-gray-500 mb-1">Height (50th %)</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="font-medium text-lg">{standard.height.p50} cm</div>
|
||||||
<div className="text-xs text-gray-400">Range: {standard.height.p3}-{standard.height.p97}</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>
|
)}
|
||||||
<div className="text-gray-500 mb-1">Head (50th %)</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="font-medium text-lg">{standard.headCircumference.p50} cm</div>
|
||||||
<div className="text-xs text-gray-400">Range: {standard.headCircumference.p3}-{standard.headCircumference.p97}</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>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -205,19 +462,19 @@ export default function GrowthPage() {
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setChartMetric("weight")}
|
onClick={() => setChartMetric("weight")}
|
||||||
className={`px-2 py-1 text-xs rounded ${chartMetric === "weight" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
|
className={`px-3 py-1 text-xs rounded ${chartMetric === "weight" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
|
||||||
>
|
>
|
||||||
Weight
|
Weight
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setChartMetric("height")}
|
onClick={() => setChartMetric("height")}
|
||||||
className={`px-2 py-1 text-xs rounded ${chartMetric === "height" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
|
className={`px-3 py-1 text-xs rounded ${chartMetric === "height" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
|
||||||
>
|
>
|
||||||
Height
|
Height
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setChartMetric("head")}
|
onClick={() => setChartMetric("head")}
|
||||||
className={`px-2 py-1 text-xs rounded ${chartMetric === "head" ? "bg-rose-400 text-white" : "bg-gray-100"}`}
|
className={`px-3 py-1 text-xs rounded ${chartMetric === "head" ? "bg-rose-400 text-white" : "bg-gray-100 dark:bg-gray-700"}`}
|
||||||
>
|
>
|
||||||
Head
|
Head
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -227,11 +484,16 @@ export default function GrowthPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Latest Reading with Percentile */}
|
{/* Latest Reading Card */}
|
||||||
{latest && (
|
{latest && (
|
||||||
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">
|
<div className="mx-4 mb-4 p-4 bg-rose-100 dark:bg-rose-900 rounded-xl">
|
||||||
<div className="text-sm text-gray-500 mb-2">
|
<div className="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||||
Latest: {new Date(latest.measured_at).toLocaleDateString()}
|
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>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{latest.weight_kg && (
|
{latest.weight_kg && (
|
||||||
|
|
@ -239,8 +501,13 @@ export default function GrowthPage() {
|
||||||
<div className="text-gray-500 text-xs">Weight</div>
|
<div className="text-gray-500 text-xs">Weight</div>
|
||||||
<div className="text-xl font-bold">{latest.weight_kg} kg</div>
|
<div className="text-xl font-bold">{latest.weight_kg} kg</div>
|
||||||
{weightPercentile && (
|
{weightPercentile && (
|
||||||
<div className={`text-xs font-medium ${weightPercentile === "Below 3rd" ? "text-red-500" : "text-green-600"}`}>
|
<div className={`text-xs font-medium ${getPercentileColor(weightPercentile)}`}>
|
||||||
{weightPercentile} percentile
|
{weightPercentile} percentile
|
||||||
|
{savedGoals.weightKg && (
|
||||||
|
<span className="ml-1">
|
||||||
|
→ {((latest.weight_kg / savedGoals.weightKg) * 100).toFixed(0)}% of goal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,8 +517,13 @@ export default function GrowthPage() {
|
||||||
<div className="text-gray-500 text-xs">Height</div>
|
<div className="text-gray-500 text-xs">Height</div>
|
||||||
<div className="text-xl font-bold">{latest.height_cm} cm</div>
|
<div className="text-xl font-bold">{latest.height_cm} cm</div>
|
||||||
{heightPercentile && (
|
{heightPercentile && (
|
||||||
<div className={`text-xs font-medium ${heightPercentile === "Below 3rd" ? "text-red-500" : "text-green-600"}`}>
|
<div className={`text-xs font-medium ${getPercentileColor(heightPercentile)}`}>
|
||||||
{heightPercentile} percentile
|
{heightPercentile} percentile
|
||||||
|
{savedGoals.heightCm && (
|
||||||
|
<span className="ml-1">
|
||||||
|
→ {((latest.height_cm / savedGoals.heightCm) * 100).toFixed(0)}% of goal
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -261,7 +533,7 @@ export default function GrowthPage() {
|
||||||
<div className="text-gray-500 text-xs">Head</div>
|
<div className="text-gray-500 text-xs">Head</div>
|
||||||
<div className="text-xl font-bold">{latest.head_circumference_cm} cm</div>
|
<div className="text-xl font-bold">{latest.head_circumference_cm} cm</div>
|
||||||
{headPercentile && (
|
{headPercentile && (
|
||||||
<div className={`text-xs font-medium ${headPercentile === "Below 3rd" ? "text-red-500" : "text-green-600"}`}>
|
<div className={`text-xs font-medium ${getPercentileColor(headPercentile)}`}>
|
||||||
{headPercentile} percentile
|
{headPercentile} percentile
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -271,15 +543,23 @@ export default function GrowthPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit Form */}
|
||||||
{showAdd && (
|
{showAdd && (
|
||||||
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-xl space-y-2">
|
<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
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
placeholder="Weight (kg)"
|
placeholder="Weight (kg)"
|
||||||
value={weight}
|
value={weight}
|
||||||
onChange={e => setWeight(e.target.value)}
|
onChange={e => setWeight(e.target.value)}
|
||||||
className="w-full p-3 border rounded-xl"
|
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -287,7 +567,7 @@ export default function GrowthPage() {
|
||||||
placeholder="Height (cm)"
|
placeholder="Height (cm)"
|
||||||
value={height}
|
value={height}
|
||||||
onChange={e => setHeight(e.target.value)}
|
onChange={e => setHeight(e.target.value)}
|
||||||
className="w-full p-3 border rounded-xl"
|
className="w-full p-3 border rounded-xl dark:bg-gray-700"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -295,26 +575,54 @@ export default function GrowthPage() {
|
||||||
placeholder="Head circumference (cm)"
|
placeholder="Head circumference (cm)"
|
||||||
value={headCircumference}
|
value={headCircumference}
|
||||||
onChange={e => setHeadCircumference(e.target.value)}
|
onChange={e => setHeadCircumference(e.target.value)}
|
||||||
className="w-full p-3 border rounded-xl"
|
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">
|
<button onClick={handleAdd} className="w-full p-3 bg-rose-400 text-white rounded-xl">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="px-4 space-y-2">
|
{/* Empty State */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500">Loading...</p>
|
<div className="p-8 text-center text-gray-500">
|
||||||
|
<p>Loading...</p>
|
||||||
|
</div>
|
||||||
) : growthData.length === 0 ? (
|
) : growthData.length === 0 ? (
|
||||||
<p className="text-gray-500">No growth records yet</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
growthData.map((record: any, i: number) => (
|
/* History List */
|
||||||
<div key={i} className="p-4 bg-white dark:bg-gray-800 rounded-xl">
|
<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">
|
<div className="text-sm text-gray-500">
|
||||||
{new Date(record.measured_at).toLocaleDateString()}
|
{new Date(record.measured_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4 mt-2">
|
<div className="flex gap-4 mt-1">
|
||||||
{record.weight_kg && (
|
{record.weight_kg && (
|
||||||
<div>⚖️ {record.weight_kg} kg</div>
|
<div>⚖️ {record.weight_kg} kg</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -326,9 +634,26 @@ export default function GrowthPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue