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
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { format, isToday, isYesterday } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, StickyNote } from "lucide-react";
interface Event {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
const PAGE_SIZE = 50;
function groupByDay(events: Event[]): Record<string, Event[]> {
const groups: Record<string, Event[]> = {};
for (const ev of events) {
const key = format(new Date(ev.startedAt), "yyyy-MM-dd");
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
}
return groups;
}
function dayLabel(key: string): string {
const d = new Date(key);
if (isToday(d)) return "Aujourd'hui";
if (isYesterday(d)) return "Hier";
return format(d, "EEEE d MMMM", { locale: fr });
}
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 TimelinePage() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [editingNotes, setEditingNotes] = useState<{ id: string; notes: string } | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [page, setPage] = useState(0);
const { data: eventsData, isLoading } = useQuery({
queryKey: ["events", selectedBaby?.id, filterType, page],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}${filterType ? `&type=${filterType}` : ""}`).then(
(r) => r.json()
),
enabled: !!selectedBaby?.id,
refetchInterval: 10_000,
});
const events: Event[] = eventsData?.events ?? [];
const total: number = eventsData?.total ?? 0;
const hasMore = (page + 1) * PAGE_SIZE < total;
const grouped = groupByDay(events);
const days = Object.keys(grouped).sort((a, b) => b.localeCompare(a));
async function deleteEvent(id: string) {
await fetch(`/api/events/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["events"] });
}
async function saveNotes(id: string, notes: string) {
await fetch(`/api/events/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
qc.invalidateQueries({ queryKey: ["events"] });
setEditingNotes(null);
}
function handleFilterChange(t: EventType | "") {
setFilterType(t);
setPage(0);
}
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Journal{selectedBaby ? `${selectedBaby.name}` : ""}
</h1>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<button
onClick={() => handleFilterChange("")}
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === "" ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
Tout {total > 0 && <span className="opacity-70 ml-0.5">({total})</span>}
</button>
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
<button
key={t}
onClick={() => handleFilterChange(filterType === t ? "" : t)}
className={`flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
filterType === t ? "bg-indigo-600 text-white border-indigo-600" : "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500"
}`}
>
<EventIcon name={EVENT_CONFIG[t].icon} className={`w-3 h-3 ${filterType === t ? "text-white" : EVENT_CONFIG[t].colorClass}`} />
{EVENT_CONFIG[t].label}
</button>
))}
</div>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
</div>
)}
<div className="space-y-6">
{days.map((day) => (
<div key={day}>
<div className="flex items-center gap-3 mb-3">
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
</div>
<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">
{grouped[day].map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
const isExpanded = expandedId === ev.id;
const summary = getEventSummary(ev);
return (
<div key={ev.id}>
<button
className="w-full text-left flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
onClick={() => setExpandedId(isExpanded ? null : ev.id)}
>
<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>
{summary && <span className="text-xs text-slate-400 dark:text-slate-500 ml-2">{summary}</span>}
{ev.notes && <p className="text-xs text-slate-400 dark:text-slate-500 truncate mt-0.5 italic">{ev.notes}</p>}
</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>
</button>
{isExpanded && (
<div className="px-4 pb-4 bg-slate-50 dark:bg-slate-800/60 border-t border-slate-100 dark:border-slate-700 space-y-3 pt-3">
<div className="grid grid-cols-3 gap-4 text-xs">
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Début</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.startedAt), "HH:mm")}</p>
</div>
{ev.endedAt && (
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Fin</p>
<p className="font-mono font-medium text-slate-700 dark:text-slate-200">{format(new Date(ev.endedAt), "HH:mm")}</p>
</div>
)}
<div>
<p className="text-slate-400 dark:text-slate-500 mb-0.5">Noté par</p>
<p className="font-medium text-slate-700 dark:text-slate-200">{ev.user.name}</p>
</div>
</div>
{editingNotes?.id === ev.id ? (
<div className="space-y-2">
<textarea
value={editingNotes.notes}
onChange={(e) => setEditingNotes({ id: ev.id, notes: e.target.value })}
rows={2}
className="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 resize-none bg-white dark:bg-slate-700 dark:text-slate-100"
placeholder="Observations..."
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => saveNotes(ev.id, editingNotes.notes)}
className="flex-1 py-1.5 bg-indigo-600 text-white text-xs rounded-lg font-medium">
Sauvegarder
</button>
<button onClick={() => setEditingNotes(null)}
className="py-1.5 px-3 border border-slate-200 dark:border-slate-600 text-xs rounded-lg text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-700">
Annuler
</button>
</div>
</div>
) : (
<button
onClick={() => setEditingNotes({ id: ev.id, notes: ev.notes ?? "" })}
className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 transition"
>
<StickyNote className="w-3.5 h-3.5" />
<span>{ev.notes ? ev.notes : "Ajouter une note..."}</span>
</button>
)}
<button
onClick={() => { if (confirm("Supprimer cet événement ?")) deleteEvent(ev.id); }}
className="flex items-center gap-1.5 text-xs text-red-400 hover:text-red-600 transition"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
</div>
)}
</div>
);
})}
</div>
</div>
))}
{hasMore && (
<button
onClick={() => setPage((p) => p + 1)}
className="w-full py-2.5 border border-slate-200 dark:border-slate-700 rounded-xl text-sm text-slate-600 dark:text-slate-300 hover:border-indigo-300 dark:hover:border-indigo-500 hover:text-indigo-600 dark:hover:text-indigo-400 bg-white dark:bg-slate-800 transition"
>
Charger plus · {total - (page + 1) * PAGE_SIZE} restants
</button>
)}
{!isLoading && events.length === 0 && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<p className="text-sm">Aucun événement enregistré</p>
</div>
)}
</div>
</div>
);
}