Files
Grow/src/app/(app)/stats/page.tsx
T
arnaudne cd16c354c0 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.
2026-06-13 01:52:46 +02:00

304 lines
14 KiB
TypeScript

"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&apos;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>
);
}