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,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EVENT_CONFIG, EventType, formatTimeAgo, formatDuration } from "@/lib/event-config";
|
||||
import { EventModal } from "@/components/event-modal";
|
||||
import { EventIcon } from "@/components/event-icon";
|
||||
import { useBaby } from "@/contexts/baby-context";
|
||||
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
||||
import { format, isToday } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
const QUICK_LOG_TYPES: EventType[] = [
|
||||
"BREASTFEED",
|
||||
"BOTTLE",
|
||||
"DIAPER_WET",
|
||||
"DIAPER_STOOL",
|
||||
"NAP",
|
||||
"NIGHT_SLEEP",
|
||||
"MEDICATION",
|
||||
"TEMPERATURE",
|
||||
];
|
||||
|
||||
interface Event {
|
||||
id: string;
|
||||
type: EventType;
|
||||
startedAt: string;
|
||||
endedAt?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
user: { id: string; name: string };
|
||||
}
|
||||
|
||||
function getBabyAge(birthDate: string): string {
|
||||
const birth = new Date(birthDate);
|
||||
const now = new Date();
|
||||
const days = Math.floor((now.getTime() - birth.getTime()) / 86400000);
|
||||
if (days < 7) return `${days} jour${days !== 1 ? "s" : ""}`;
|
||||
const weeks = Math.floor(days / 7);
|
||||
if (weeks < 8) return `${weeks} semaine${weeks !== 1 ? "s" : ""}`;
|
||||
const months = Math.floor(days / 30);
|
||||
return `${months} mois`;
|
||||
}
|
||||
|
||||
function getEventSummary(event: Event): string {
|
||||
const meta = event.metadata ?? {};
|
||||
if (event.type === "BREASTFEED") {
|
||||
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
|
||||
if (event.endedAt) return `${formatDuration(new Date(event.startedAt), new Date(event.endedAt))} · ${side}`;
|
||||
return side;
|
||||
}
|
||||
if (event.type === "BOTTLE") {
|
||||
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
|
||||
}
|
||||
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
|
||||
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
|
||||
if (event.type === "MEDICATION") return String(meta.name ?? "");
|
||||
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
||||
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
||||
const { selectedBaby, isLoading: familyLoading } = useBaby();
|
||||
|
||||
const { data: eventsData } = useQuery({
|
||||
queryKey: ["events", selectedBaby?.id],
|
||||
queryFn: () => fetch(`/api/events?babyId=${selectedBaby!.id}&limit=50`).then((r) => r.json()),
|
||||
enabled: !!selectedBaby?.id,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const events: Event[] = eventsData?.events ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
|
||||
const id = setInterval(() => {
|
||||
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds());
|
||||
}, 5 * 60 * 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [events]);
|
||||
|
||||
const lastByType: Partial<Record<EventType, Event>> = {};
|
||||
for (const ev of events) {
|
||||
if (!lastByType[ev.type]) lastByType[ev.type] = ev;
|
||||
}
|
||||
|
||||
const todayEvents = events.filter((e) => isToday(new Date(e.startedAt)));
|
||||
const todayCounts: Partial<Record<EventType, number>> = {};
|
||||
for (const ev of todayEvents) todayCounts[ev.type] = (todayCounts[ev.type] ?? 0) + 1;
|
||||
|
||||
if (familyLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedBaby) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-8 text-center">
|
||||
<p className="text-slate-500 mb-4">Aucun bébé enregistré.</p>
|
||||
<a href="/setup" className="bg-indigo-600 text-white px-5 py-2.5 rounded-lg font-medium hover:bg-indigo-700 transition text-sm">
|
||||
Ajouter un bébé
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
{/* Page header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">{selectedBaby.name}</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-0.5">{getBabyAge(selectedBaby.birthDate)}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-slate-400 dark:text-slate-500 text-xs">Aujourd'hui</p>
|
||||
<p className="text-slate-700 dark:text-slate-200 font-medium text-sm capitalize">
|
||||
{format(new Date(), "EEEE d MMMM", { locale: fr })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's summary counters */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
|
||||
const cfg = EVENT_CONFIG[t];
|
||||
return (
|
||||
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
|
||||
<EventIcon name={cfg.icon} className={`w-5 h-5 mx-auto mb-1.5 ${cfg.colorClass}`} />
|
||||
<div className="text-2xl font-bold text-slate-900 dark:text-slate-100">{todayCounts[t] ?? 0}</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 mt-0.5 leading-tight">{cfg.label.split(" ")[0]}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Last events */}
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Dernières activités</h2>
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL", "NAP"] as EventType[]).map((t) => {
|
||||
const last = lastByType[t];
|
||||
const cfg = EVENT_CONFIG[t];
|
||||
return (
|
||||
<div key={t} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</div>
|
||||
{last && getEventSummary(last) && (
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 truncate">{getEventSummary(last)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0">
|
||||
{last ? formatTimeAgo(new Date(last.startedAt)) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick log */}
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Enregistrer</h2>
|
||||
<div className="grid grid-cols-4 md:grid-cols-4 gap-2">
|
||||
{QUICK_LOG_TYPES.map((type) => {
|
||||
const cfg = EVENT_CONFIG[type];
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setActiveModal(type)}
|
||||
className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:border-slate-300 hover:shadow-sm rounded-xl p-3 flex flex-col items-center gap-2 transition active:scale-95"
|
||||
>
|
||||
<div className={`w-9 h-9 rounded-lg ${cfg.bgClass} flex items-center justify-center`}>
|
||||
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-slate-600 dark:text-slate-300 text-center leading-tight">
|
||||
{cfg.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's timeline */}
|
||||
{todayEvents.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">
|
||||
Aujourd'hui · {todayEvents.length} événement{todayEvents.length > 1 ? "s" : ""}
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{todayEvents.slice(0, 10).map((ev) => {
|
||||
const cfg = EVENT_CONFIG[ev.type];
|
||||
return (
|
||||
<div key={ev.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
|
||||
{getEventSummary(ev) && (
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{getEventSummary(ev)}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500 flex-shrink-0 font-mono">
|
||||
{format(new Date(ev.startedAt), "HH:mm")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeModal && (
|
||||
<EventModal
|
||||
type={activeModal}
|
||||
babyId={selectedBaby.id}
|
||||
onClose={() => setActiveModal(null)}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user