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:
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
LineChart, Line, CartesianGrid,
|
||||
} from "recharts";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { format, subDays, startOfDay, endOfDay } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { EventType } from "@/lib/event-config";
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
type: EventType;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
}
|
||||
|
||||
function buildWeeklyData(events: Event[], days: number) {
|
||||
const labelFormat = days <= 7 ? "EEE" : "d/M";
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const d = subDays(new Date(), days - 1 - i);
|
||||
const start = startOfDay(d).getTime();
|
||||
const end = endOfDay(d).getTime();
|
||||
const dayEvents = events.filter((e) => {
|
||||
const t = new Date(e.startedAt).getTime();
|
||||
return t >= start && t <= end;
|
||||
});
|
||||
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
||||
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
||||
const sleepMins = dayEvents
|
||||
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
||||
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
|
||||
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(totalMins: number): string {
|
||||
const h = Math.floor(totalMins / 60);
|
||||
const m = Math.round(totalMins % 60);
|
||||
return `${h}h ${m.toString().padStart(2, "0")}min`;
|
||||
}
|
||||
|
||||
interface SleepStats {
|
||||
totalHours: number;
|
||||
avgPerDay: number;
|
||||
longestStretchMins: number;
|
||||
}
|
||||
|
||||
function computeSleepStats(events: Event[], days: number): SleepStats {
|
||||
const sleepEvents = events.filter(
|
||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||
);
|
||||
const durations = sleepEvents.map(
|
||||
(e) => (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000
|
||||
);
|
||||
const totalMins = durations.reduce((acc, d) => acc + d, 0);
|
||||
const longestStretchMins = durations.length > 0 ? Math.max(...durations) : 0;
|
||||
return {
|
||||
totalHours: Math.round((totalMins / 60) * 10) / 10,
|
||||
avgPerDay: Math.round((totalMins / 60 / days) * 10) / 10,
|
||||
longestStretchMins: Math.round(longestStretchMins),
|
||||
};
|
||||
}
|
||||
|
||||
interface FeedingStats {
|
||||
breastfeedMinutes: number;
|
||||
bottleCount: number;
|
||||
}
|
||||
|
||||
function computeFeedingStats(events: Event[]): FeedingStats {
|
||||
const breastfeedEvents = events.filter(
|
||||
(e) => e.type === "BREASTFEED" && e.endedAt
|
||||
);
|
||||
const breastfeedMinutes = Math.round(
|
||||
breastfeedEvents.reduce(
|
||||
(acc, e) =>
|
||||
acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000,
|
||||
0
|
||||
)
|
||||
);
|
||||
const bottleCount = events.filter((e) => e.type === "BOTTLE").length;
|
||||
return { breastfeedMinutes, bottleCount };
|
||||
}
|
||||
|
||||
function computeFeedInterval(events: Event[]): string {
|
||||
const feeds = events
|
||||
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
|
||||
.map((e) => new Date(e.startedAt).getTime())
|
||||
.sort((a, b) => a - b);
|
||||
if (feeds.length < 2) return "—";
|
||||
const intervals = feeds.slice(1).map((t, i) => t - feeds[i]);
|
||||
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const hours = Math.floor(avgMs / 3600000);
|
||||
const mins = Math.round((avgMs % 3600000) / 60000);
|
||||
return `${hours}h${mins.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const DAY_OPTIONS = [7, 14, 30, 90] as const;
|
||||
type DaysOption = typeof DAY_OPTIONS[number];
|
||||
|
||||
function daysLabel(days: DaysOption): string {
|
||||
if (days === 7) return "7j";
|
||||
if (days === 14) return "14j";
|
||||
if (days === 30) return "30j";
|
||||
return "90j";
|
||||
}
|
||||
|
||||
function periodSubtitle(days: DaysOption): string {
|
||||
if (days === 7) return "7 derniers jours";
|
||||
if (days === 14) return "14 derniers jours";
|
||||
if (days === 30) return "30 derniers jours";
|
||||
return "90 derniers jours";
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const { selectedBaby: baby } = useBaby();
|
||||
const isDark = useDarkMode();
|
||||
const [days, setDays] = useState<DaysOption>(7);
|
||||
|
||||
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 cursorFill = isDark ? "rgba(255,255,255,0.04)" : "#f8fafc";
|
||||
|
||||
const { data: eventsData } = useQuery({
|
||||
queryKey: ["events", baby?.id, "stats", days],
|
||||
queryFn: () =>
|
||||
fetch(`/api/events?babyId=${baby!.id}&limit=500&dateFrom=${subDays(new Date(), days - 1).toISOString()}`).then((r) => r.json()),
|
||||
enabled: !!baby?.id,
|
||||
});
|
||||
|
||||
const events: Event[] = eventsData?.events ?? [];
|
||||
const weeklyData = buildWeeklyData(events, days);
|
||||
const feedInterval = computeFeedInterval(events);
|
||||
const sleepStats = computeSleepStats(events, days);
|
||||
const feedingStats = computeFeedingStats(events);
|
||||
|
||||
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
|
||||
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
||||
const todayDiapers = today.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
||||
const todaySleepMins = today
|
||||
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
||||
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Statistiques</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{periodSubtitle(days)}</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{DAY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
onClick={() => setDays(opt)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition ${
|
||||
days === opt
|
||||
? "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"
|
||||
}`}
|
||||
>
|
||||
{daysLabel(opt)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today summary */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
{[
|
||||
{ label: "Repas", value: todayFeeds, color: "text-rose-600", bg: "bg-rose-50 dark:bg-rose-950", border: "border-rose-100 dark:border-rose-900" },
|
||||
{ label: "Couches", value: todayDiapers, color: "text-amber-600", bg: "bg-amber-50 dark:bg-amber-950", border: "border-amber-100 dark:border-amber-900" },
|
||||
{ label: "Sommeil", value: `${Math.round(todaySleepMins / 60 * 10) / 10}h`, color: "text-violet-600", bg: "bg-violet-50 dark:bg-violet-950", border: "border-violet-100 dark:border-violet-900" },
|
||||
].map(({ label, value, color, bg, border }) => (
|
||||
<div key={label} className={`${bg} border ${border} rounded-xl p-4 text-center`}>
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 mt-1">{label} · aujourd'hui</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feed interval */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">Intervalle moyen entre les repas</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{periodSubtitle(days)}</p>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-indigo-600 font-mono">{feedInterval}</span>
|
||||
</div>
|
||||
|
||||
{/* Sleep summary */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé du sommeil</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Total</p>
|
||||
<p className="text-lg font-bold text-violet-600 leading-tight">
|
||||
{formatDuration(sleepStats.totalHours * 60)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Moy. / jour</p>
|
||||
<p className="text-lg font-bold text-violet-600 leading-tight">
|
||||
{formatDuration(sleepStats.avgPerDay * 60)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Plus longue nuit</p>
|
||||
<p className="text-lg font-bold text-violet-600 leading-tight">
|
||||
{sleepStats.longestStretchMins > 0 ? formatDuration(sleepStats.longestStretchMins) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feeding summary */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé des repas</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">{periodSubtitle(days)}</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Durée allaitement</p>
|
||||
<p className="text-lg font-bold text-rose-600 leading-tight">
|
||||
{feedingStats.breastfeedMinutes > 0 ? formatDuration(feedingStats.breastfeedMinutes) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Biberons</p>
|
||||
<p className="text-lg font-bold text-rose-600 leading-tight">{feedingStats.bottleCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Intervalle moy.</p>
|
||||
<p className="text-lg font-bold text-rose-600 leading-tight font-mono">{feedInterval}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{/* Feeds */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Repas par jour</h3>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(val) => [val, "Repas"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
|
||||
<Bar dataKey="feeds" fill="#4f46e5" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Diapers */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Couches par jour</h3>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(val) => [val, "Couches"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
|
||||
<Bar dataKey="diapers" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Sleep */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={weeklyData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<Tooltip formatter={(val) => [`${val}h`, "Sommeil"]} contentStyle={tooltipStyle} />
|
||||
<Line dataKey="sleep" stroke="#7c3aed" strokeWidth={2} dot={{ r: 4, fill: "#7c3aed", strokeWidth: 2, stroke: "#fff" }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user