"use client"; import { useState, useEffect } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, } from "recharts"; import { WHO_WEIGHT_PERCENTILES, PERCENTILE_LABELS, getAgeInWeeks, interpolatePercentile, } from "@/lib/who-percentiles"; import { useBaby } from "@/contexts/baby-context"; import { format, subDays } from "date-fns"; import { fr } from "date-fns/locale"; import { Plus, Pencil, Trash2, FileDown } from "lucide-react"; interface GrowthLog { id: string; date: string; weight?: number; height?: number; headCirc?: number; notes?: string; } interface BabyShape { id: string; name: string; birthDate: string; } const WHO_CURVE_KEYS = ["p3", "p10", "p25", "p50", "p75", "p90", "p97"]; const WHO_COLORS = ["#e2e8f0", "#cbd5e1", "#94a3b8", "#475569", "#94a3b8", "#cbd5e1", "#e2e8f0"]; function useDarkMode() { const [isDark, setIsDark] = useState(false); useEffect(() => { const check = () => setIsDark(document.documentElement.classList.contains("dark")); check(); const obs = new MutationObserver(check); obs.observe(document.documentElement, { attributeFilter: ["class"] }); return () => obs.disconnect(); }, []); return isDark; } type TimeRange = "7j" | "4sem" | "3mois" | "tout"; function filterLogsByRange(logs: GrowthLog[], range: TimeRange): GrowthLog[] { if (range === "tout") return logs; const now = new Date(); let cutoff: Date; if (range === "7j") cutoff = subDays(now, 7); else if (range === "4sem") cutoff = subDays(now, 28); else cutoff = subDays(now, 90); return logs.filter((l) => new Date(l.date) >= cutoff); } const MS_PER_WEEK = 7 * 24 * 60 * 60 * 1000; function ageWeeksFloat(birthDate: string, measureDate: string): number { return (new Date(measureDate).getTime() - new Date(birthDate).getTime()) / MS_PER_WEEK; } function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) { const actualPoints = logs .filter((l) => l.weight) .map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), weight: l.weight! / 1000 })); if (actualPoints.length === 0) return []; const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks)); const globalMin = allLogs.filter((l) => l.weight).reduce((min, l) => { const w = ageWeeksFloat(baby.birthDate, l.date); return w < min ? w : min; }, Infinity); const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks)); const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0; const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES) .map(Number) .filter((a) => a >= Math.floor(minWeek) && a <= Math.ceil(maxWeek) + 2) .sort((a, b) => a - b); // Use string keys so float actual-point ages don't collide with integer WHO ages const rows = new Map>(); for (const ageWeeks of whoAges) { const row: Record = { ageWeeks }; PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); }); rows.set(ageWeeks, row); } for (const pt of actualPoints) { const existing = rows.get(pt.ageWeeks) ?? { ageWeeks: pt.ageWeeks }; rows.set(pt.ageWeeks, { ...existing, actualWeight: pt.weight }); } return Array.from(rows.values()).sort((a, b) => (a.ageWeeks as number) - (b.ageWeeks as number)); } function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height" | "headCirc") { return logs .filter((l) => l[field]) .map((l) => ({ ageWeeks: ageWeeksFloat(baby.birthDate, l.date), value: l[field]! })) .sort((a, b) => a.ageWeeks - b.ageWeeks); } const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"; function fmtAgeWeeks(weeks: number): string { if (weeks === 0) return "Naiss."; if (weeks < 4) return `${weeks}sem`; const months = Math.round(weeks / 4.345); if (months >= 12) return "1 an"; return `${months}m`; } function fmtAgeLabel(weeks: number): string { if (weeks === 0) return "Naissance"; if (weeks < 4) return `${weeks} semaine${weeks > 1 ? "s" : ""}`; const months = Math.floor(weeks / 4.345); const remWeeks = Math.round(weeks - months * 4.345); if (months >= 12) return "1 an"; if (remWeeks > 0) return `${months} mois ${remWeeks} sem.`; return `${months} mois`; } function WeightTooltip(props: Record & { style: React.CSSProperties }) { const { active, payload, label, style } = props as { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: number; style: React.CSSProperties; }; if (!active || !payload) return null; const baby = payload.find((p) => p.dataKey === "actualWeight"); if (!baby) return null; return (

{fmtAgeLabel(label ?? 0)}

Bébé : {baby.value.toFixed(3)} kg

); } export default function GrowthPage() { const qc = useQueryClient(); const { selectedBaby: baby } = useBaby(); const isDark = useDarkMode(); const tooltipStyle = { borderRadius: 8, border: `1px solid ${isDark ? "#334155" : "#e2e8f0"}`, background: isDark ? "#1e293b" : "#fff", color: isDark ? "#f1f5f9" : "#0f172a", boxShadow: "0 4px 16px rgba(0,0,0,0.12)", fontSize: 12, }; const gridStroke = isDark ? "#1e293b" : "#f1f5f9"; const [showForm, setShowForm] = useState(false); const [form, setForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" }); function openAddForm() { const last = logs.at(-1); setForm({ date: format(new Date(), "yyyy-MM-dd"), weight: last?.weight ? (last.weight / 1000).toFixed(3) : "", height: last?.height ? String(last.height) : "", headCirc: last?.headCirc ? String(last.headCirc) : "", notes: "", }); setShowForm(true); } const [saving, setSaving] = useState(false); const [timeRange, setTimeRange] = useState("tout"); const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" }); const [editSaving, setEditSaving] = useState(false); const { data: logs = [] } = useQuery({ queryKey: ["growth", baby?.id], queryFn: () => fetch(`/api/growth?babyId=${baby!.id}`).then((r) => r.json()), enabled: !!baby?.id, }); const filteredLogs = filterLogsByRange(logs, timeRange); const weightData = baby ? buildWeightChartData(filteredLogs, baby, logs) : []; const heightData = baby ? buildSimpleChartData(filteredLogs, baby, "height") : []; const headData = baby ? buildSimpleChartData(filteredLogs, baby, "headCirc") : []; async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); await fetch("/api/growth", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ babyId: baby!.id, date: new Date(form.date).toISOString(), weight: form.weight ? parseFloat(form.weight) * 1000 : null, height: form.height ? parseFloat(form.height) : null, headCirc: form.headCirc ? parseFloat(form.headCirc) : null, notes: form.notes || null, }), }); qc.invalidateQueries({ queryKey: ["growth"] }); setSaving(false); setShowForm(false); setForm({ date: "", weight: "", height: "", headCirc: "", notes: "" }); } function startEdit(log: GrowthLog) { setEditingId(log.id); setEditForm({ date: format(new Date(log.date), "yyyy-MM-dd"), weight: log.weight ? (log.weight / 1000).toFixed(3) : "", height: log.height ? String(log.height) : "", headCirc: log.headCirc ? String(log.headCirc) : "", notes: log.notes ?? "", }); } async function handleEditSave(e: React.FormEvent, logId: string) { e.preventDefault(); setEditSaving(true); await fetch(`/api/growth/${logId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ date: new Date(editForm.date).toISOString(), weight: editForm.weight ? parseFloat(editForm.weight) * 1000 : null, height: editForm.height ? parseFloat(editForm.height) : null, headCirc: editForm.headCirc ? parseFloat(editForm.headCirc) : null, notes: editForm.notes || null, }), }); qc.invalidateQueries({ queryKey: ["growth"] }); setEditSaving(false); setEditingId(null); } async function handleDelete(logId: string) { if (!confirm("Supprimer cette mesure ?")) return; await fetch(`/api/growth/${logId}`, { method: "DELETE" }); qc.invalidateQueries({ queryKey: ["growth"] }); } function exportPDF() { function getPercentileBand(ageWeeks: number, weightKg: number): string { const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0); if (weightKg < values[0]) return "< P3"; for (let i = 0; i < values.length - 1; i++) { if (weightKg >= values[i] && weightKg < values[i + 1]) return `${PERCENTILE_LABELS[i]}–${PERCENTILE_LABELS[i + 1]}`; } return "> P97"; } import("jspdf").then(({ default: jsPDF }) => { const doc = new jsPDF(); const pageW = doc.internal.pageSize.getWidth(); doc.setFontSize(18); doc.setTextColor(30, 41, 59); doc.text("Courbe de croissance", 14, 20); doc.setFontSize(11); doc.setTextColor(100, 116, 139); doc.text(`${baby!.name} · né${baby!.gender === "F" ? "e" : ""} le ${format(new Date(baby!.birthDate), "d MMMM yyyy", { locale: fr })}`, 14, 28); doc.text(`Généré le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`, 14, 34); // Table header const COL = [14, 55, 95, 130, 155]; const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"]; let y = 46; doc.setFillColor(238, 242, 255); doc.rect(14, y - 5, pageW - 28, 9, "F"); doc.setFontSize(9); doc.setTextColor(79, 70, 229); headers.forEach((h, i) => doc.text(h, COL[i], y)); y += 8; doc.setFontSize(9); const sorted = [...logs].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); sorted.forEach((log, idx) => { if (y > 270) { doc.addPage(); y = 20; } if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(14, y - 4, pageW - 28, 7, "F"); } doc.setTextColor(30, 41, 59); doc.text(format(new Date(log.date), "d MMM yyyy", { locale: fr }), COL[0], y); doc.text(log.weight ? (() => { const ageW = getAgeInWeeks(new Date(baby!.birthDate), new Date(log.date)); const band = ageW >= 0 && ageW <= 104 ? ` (${getPercentileBand(ageW, log.weight! / 1000)})` : ""; return `${(log.weight! / 1000).toFixed(3)}${band}`; })() : "—", COL[1], y); doc.text(log.height ? String(log.height) : "—", COL[2], y); doc.text(log.headCirc ? String(log.headCirc) : "—", COL[3], y); if (log.notes) doc.text(log.notes.slice(0, 30), COL[4], y); y += 7; }); doc.save(`croissance-${baby!.name.toLowerCase()}.pdf`); }); } const latest = logs.at(-1); const rangeOptions: { label: string; value: TimeRange }[] = [ { label: "7j", value: "7j" }, { label: "4 sem", value: "4sem" }, { label: "3 mois", value: "3mois" }, { label: "Tout", value: "tout" }, ]; return (

Courbes de croissance

{baby?.name}

{logs.length > 0 && ( )}
{/* Add measurement form */} {showForm && (

Nouvelle mesure

setForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
setForm((f) => ({ ...f, weight: e.target.value }))} step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
setForm((f) => ({ ...f, height: e.target.value }))} step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
setForm((f) => ({ ...f, headCirc: e.target.value }))} step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
setForm((f) => ({ ...f, notes: e.target.value }))} placeholder="RDV pédiatre, contexte..." className={inputCls} />
)} {/* Latest values */} {latest && (
{latest.weight && (

Poids

{(latest.weight / 1000).toFixed(3).replace(".", ",")} kg

)} {latest.height && (

Taille

{latest.height} cm

)} {latest.headCirc && (

Périmètre crânien (PC)

{latest.headCirc} cm

)}
)} {/* Time range selector */} {logs.length > 0 && (
{rangeOptions.map((opt) => ( ))}
)}
{/* Weight + WHO */} {weightData.length > 0 && (

Poids (kg)

Bébé OMS P3–P97
} /> {WHO_CURVE_KEYS.map((key, i) => ( ))}
)}
{/* Height */} {heightData.length > 0 && (

Taille (cm)

[`${val} cm`, "Taille"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
)} {/* Head circumference */} {headData.length > 0 && (

Périmètre crânien (cm)

[`${val} cm`, "PC"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
)}
{/* History */} {logs.length > 0 && (

Historique

{[...logs].reverse().map((log) => (
{editingId === log.id ? (
handleEditSave(e, log.id)} className="space-y-3">
setEditForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
setEditForm((f) => ({ ...f, weight: e.target.value }))} step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
setEditForm((f) => ({ ...f, height: e.target.value }))} step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
setEditForm((f) => ({ ...f, headCirc: e.target.value }))} step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
setEditForm((f) => ({ ...f, notes: e.target.value }))} placeholder="RDV pédiatre, contexte..." className={inputCls} />
) : (
{format(new Date(log.date), "d MMMM yyyy", { locale: fr })}
{log.weight && {(log.weight / 1000).toFixed(3)} kg} {log.height && {log.height} cm} {log.headCirc && PC {log.headCirc} cm}
)}
))}
)} {logs.length === 0 && !showForm && (

Aucune mesure enregistrée

)}
); }