Initial commit — Grow baby tracker

Next.js 16 App Router, Prisma + PostgreSQL, NextAuth v5 JWT.

Features: dashboard quick-log, timeline, growth charts (WHO percentiles),
stats, journal/notes, doctor notes, milestones, vaccinations, settings,
superadmin panel. Mobile-first with sidebar nav + bottom nav + quick-add FAB.
Dark mode, PWA push notifications, multi-family invite system.

Docker: multi-stage Dockerfile + docker-compose with postgres service.
This commit is contained in:
2026-06-13 01:52:46 +02:00
commit cd16c354c0
94 changed files with 14354 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
"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 } 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);
}
function buildWeightChartData(logs: GrowthLog[], baby: BabyShape, allLogs: GrowthLog[]) {
const actualPoints = logs
.filter((l) => l.weight)
.map((l) => ({
ageWeeks: getAgeInWeeks(new Date(baby.birthDate), new Date(l.date)),
weight: l.weight! / 1000,
}));
if (actualPoints.length === 0) return [];
const maxWeek = Math.max(...actualPoints.map((p) => p.ageWeeks));
// minWeek: either earliest in filtered set, or 0 if showing all
const allPoints = allLogs.filter((l) => l.weight).map((l) =>
getAgeInWeeks(new Date(baby.birthDate), new Date(l.date))
);
const globalMin = allPoints.length ? Math.min(...allPoints) : 0;
const localMin = Math.min(...actualPoints.map((p) => p.ageWeeks));
// Only clip if the range is meaningfully narrower than global
const minWeek = localMin > globalMin ? Math.max(0, localMin - 1) : 0;
const whoAges = Object.keys(WHO_WEIGHT_PERCENTILES)
.map(Number)
.filter((a) => a >= minWeek && a <= maxWeek + 2)
.sort((a, b) => a - b);
const mergedMap: Record<number, Record<string, number | null>> = {};
for (const ageWeeks of whoAges) {
const row: Record<string, number | null> = { ageWeeks };
PERCENTILE_LABELS.forEach((label, i) => { row[`p${label.slice(1)}`] = interpolatePercentile(ageWeeks, i); });
mergedMap[ageWeeks] = row;
}
for (const pt of actualPoints) {
mergedMap[pt.ageWeeks] = { ...(mergedMap[pt.ageWeeks] ?? { ageWeeks: pt.ageWeeks }), actualWeight: pt.weight };
}
return Object.values(mergedMap).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: getAgeInWeeks(new Date(baby.birthDate), new Date(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 WeightTooltip(props: Record<string, unknown> & { 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 (
<div style={style} className="px-3 py-2 rounded-lg text-xs">
<p className="text-slate-400 mb-1">Semaine {label}</p>
<p className="font-semibold text-indigo-500">Bébé : {baby.value.toFixed(3)} kg</p>
</div>
);
}
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: "",
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<TimeRange>("tout");
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
const [editSaving, setEditSaving] = useState(false);
const { data: logs = [] } = useQuery<GrowthLog[]>({
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"] });
}
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 (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
</div>
<button
onClick={() => showForm ? setShowForm(false) : openAddForm()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Mesure
</button>
</div>
{/* Add measurement form */}
{showForm && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 mb-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Nouvelle mesure</h3>
<form onSubmit={handleSave} className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
<input type="date" value={form.date} onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
<input type="number" value={form.weight} onChange={(e) => setForm((f) => ({ ...f, weight: e.target.value }))}
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
<input type="number" value={form.height} onChange={(e) => setForm((f) => ({ ...f, height: e.target.value }))}
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
<input type="number" value={form.headCirc} onChange={(e) => setForm((f) => ({ ...f, headCirc: e.target.value }))}
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
</div>
</div>
<input type="text" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="RDV pédiatre, contexte..." className={inputCls} />
<div className="flex gap-2">
<button type="button" onClick={() => setShowForm(false)} className="flex-1 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300">Annuler</button>
<button type="submit" disabled={saving} className="flex-1 py-2.5 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
{saving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
)}
{/* Latest values */}
{latest && (
<div className="grid grid-cols-3 gap-3 mb-5">
{latest.weight && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Poids</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{(latest.weight / 1000).toFixed(3).replace(".", ",")} kg</p>
</div>
)}
{latest.height && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Taille</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.height} cm</p>
</div>
)}
{latest.headCirc && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 text-center">
<p className="text-xs text-slate-400 dark:text-slate-500 font-medium mb-1">Périmètre crânien</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{latest.headCirc} cm</p>
</div>
)}
</div>
)}
{/* Time range selector */}
{logs.length > 0 && (
<div className="flex gap-1.5 mb-5">
{rangeOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setTimeRange(opt.value)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
timeRange === opt.value
? "bg-indigo-600 text-white"
: "bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600"
}`}
>
{opt.label}
</button>
))}
</div>
)}
<div className="space-y-4">
{/* Weight + WHO */}
{weightData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-indigo-600 rounded" />Bébé</span>
<span className="flex items-center gap-1"><span className="inline-block w-3 h-0.5 bg-slate-300 rounded" />OMS P3P97</span>
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} />
{WHO_CURVE_KEYS.map((key, i) => (
<Line key={key} dataKey={key} stroke={WHO_COLORS[i]} strokeWidth={key === "p50" ? 1.5 : 1} dot={false} strokeDasharray={key === "p50" ? undefined : "3 2"} />
))}
<Line dataKey="actualWeight" stroke="#4f46e5" strokeWidth={2.5} dot={{ r: 4, fill: "#4f46e5", strokeWidth: 2, stroke: "#fff" }} connectNulls={false} />
</LineChart>
</ResponsiveContainer>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Height */}
{heightData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Taille (cm)</h3>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Head circumference */}
{headData.length > 1 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Périmètre crânien (cm)</h3>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} />
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
{/* History */}
{logs.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-slate-100 dark:border-slate-700">
<h3 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Historique</h3>
</div>
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{[...logs].reverse().map((log) => (
<div key={log.id}>
{editingId === log.id ? (
<div className="px-4 py-4 bg-slate-50 dark:bg-slate-900">
<form onSubmit={(e) => handleEditSave(e, log.id)} className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Date</label>
<input type="date" value={editForm.date} onChange={(e) => setEditForm((f) => ({ ...f, date: e.target.value }))} required className={inputCls} />
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Poids (kg)</label>
<input type="number" value={editForm.weight} onChange={(e) => setEditForm((f) => ({ ...f, weight: e.target.value }))}
step="0.001" min="1" max="30" placeholder="3.500" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Taille (cm)</label>
<input type="number" value={editForm.height} onChange={(e) => setEditForm((f) => ({ ...f, height: e.target.value }))}
step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">PC (cm)</label>
<input type="number" value={editForm.headCirc} onChange={(e) => setEditForm((f) => ({ ...f, headCirc: e.target.value }))}
step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
</div>
</div>
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
placeholder="RDV pédiatre, contexte..." className={inputCls} />
<div className="flex gap-2">
<button type="button" onClick={() => setEditingId(null)} className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300">Annuler</button>
<button type="submit" disabled={editSaving} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition">
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
</div>
) : (
<div className="px-4 py-3 flex items-center justify-between group">
<span className="text-sm text-slate-600 dark:text-slate-300">
{format(new Date(log.date), "d MMMM yyyy", { locale: fr })}
</span>
<div className="flex items-center gap-3">
<div className="flex gap-4 text-xs text-slate-500 dark:text-slate-400 font-mono">
{log.weight && <span>{(log.weight / 1000).toFixed(3)} kg</span>}
{log.height && <span>{log.height} cm</span>}
{log.headCirc && <span>PC {log.headCirc} cm</span>}
</div>
<div className="flex gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button
onClick={() => startEdit(log)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDelete(log.id)}
className="p-1.5 rounded-md text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{logs.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucune mesure enregistrée</p>
<button onClick={openAddForm} className="mt-3 text-indigo-600 font-medium text-sm hover:underline">
Ajouter la première mesure
</button>
</div>
)}
</div>
</div>
);
}