cd16c354c0
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.
304 lines
14 KiB
TypeScript
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'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>
|
|
);
|
|
}
|