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
+354
View File
@@ -0,0 +1,354 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { useBaby } from "@/contexts/baby-context";
import {
format,
subDays,
addDays,
isToday,
isYesterday,
isTomorrow,
isFuture,
startOfDay,
parseISO,
} from "date-fns";
import { fr } from "date-fns/locale";
import { ChevronLeft, ChevronRight, CalendarDays, Save, Trash2 } from "lucide-react";
interface JournalNote {
id: string;
babyId: string;
userId: string;
date: string;
content: string;
createdAt: string;
updatedAt: string;
user: { id: string; name: string };
}
function formatDateLabel(date: Date): string {
if (isToday(date)) return "Aujourd'hui";
if (isYesterday(date)) return "Hier";
if (isTomorrow(date)) return "Demain";
return format(date, "d MMMM yyyy", { locale: fr });
}
function toDateString(date: Date): string {
return format(date, "yyyy-MM-dd");
}
export default function NotesPage() {
const { data: session } = useSession();
const { selectedBaby } = useBaby();
const qc = useQueryClient();
const [currentDate, setCurrentDate] = useState<Date>(startOfDay(new Date()));
const [draftContent, setDraftContent] = useState<string>("");
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const babyId = selectedBaby?.id;
const dateStr = toDateString(currentDate);
const userRole = (session?.user as { role?: string } | undefined)?.role;
const isReadonly = userRole === "READONLY";
// Query for current day note
const { data: dayNotes, isLoading: isDayLoading } = useQuery<JournalNote[]>({
queryKey: ["journal", babyId, dateStr],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}&date=${dateStr}`).then((r) => r.json()),
enabled: !!babyId,
});
const currentNote = dayNotes?.[0] ?? null;
// Query for recent notes (last 10 days that have notes)
const { data: recentNotes } = useQuery<JournalNote[]>({
queryKey: ["journal-recent", babyId],
queryFn: () =>
fetch(`/api/journal?babyId=${babyId}`).then((r) => r.json()),
enabled: !!babyId,
staleTime: 30_000,
});
// Build list of last 10 unique days with notes, excluding current date
const recentDays: { dateStr: string; note: JournalNote }[] = (() => {
if (!recentNotes) return [];
const seen = new Set<string>();
const result: { dateStr: string; note: JournalNote }[] = [];
for (const note of recentNotes) {
const ds = toDateString(new Date(note.date));
if (ds === dateStr) continue;
if (!seen.has(ds)) {
seen.add(ds);
result.push({ dateStr: ds, note });
}
if (result.length >= 10) break;
}
return result;
})();
// Sync draft content when note or date changes
useEffect(() => {
if (isDayLoading) return;
setDraftContent(currentNote?.content ?? "");
setSaveError(null);
}, [currentNote?.id, dateStr, isDayLoading]);
function goToPrevDay() {
setCurrentDate((d) => startOfDay(subDays(d, 1)));
}
function goToNextDay() {
const next = startOfDay(addDays(currentDate, 1));
if (isFuture(next)) return;
setCurrentDate(next);
}
function goToToday() {
setCurrentDate(startOfDay(new Date()));
}
const canGoNext = !isToday(currentDate);
async function handleSave() {
if (!babyId || isReadonly) return;
const trimmed = draftContent.trim();
setIsSaving(true);
setSaveError(null);
try {
if (currentNote) {
const res = await fetch(`/api/journal/${currentNote.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
} else {
if (!trimmed) return;
const res = await fetch("/api/journal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId, date: dateStr, content: trimmed }),
});
if (!res.ok) throw new Error("Erreur lors de la sauvegarde");
}
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Erreur inconnue");
} finally {
setIsSaving(false);
}
}
async function handleBlur() {
if (isReadonly) return;
const trimmed = draftContent.trim();
// Only auto-save if content changed from what's stored
const stored = currentNote?.content ?? "";
if (trimmed === stored) return;
// Don't auto-save empty content if there's no existing note
if (!trimmed && !currentNote) return;
await handleSave();
}
async function handleDelete() {
if (!currentNote || isReadonly) return;
setIsDeleting(true);
try {
await fetch(`/api/journal/${currentNote.id}`, { method: "DELETE" });
setDraftContent("");
qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] });
qc.invalidateQueries({ queryKey: ["journal-recent", babyId] });
} finally {
setIsDeleting(false);
}
}
function jumpToDate(ds: string) {
setCurrentDate(startOfDay(parseISO(ds)));
}
const hasUnsavedChanges = draftContent.trim() !== (currentNote?.content ?? "").trim();
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Journal
</h1>
{selectedBaby && (
<p className="text-sm text-slate-400 dark:text-slate-500 mt-1">
Notes quotidiennes pour {selectedBaby.name}
</p>
)}
</div>
{!selectedBaby ? (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
Sélectionnez un bébé pour voir le journal.
</div>
) : (
<>
{/* Date Navigation */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<div className="flex items-center justify-between gap-2">
<button
onClick={goToPrevDay}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors"
aria-label="Jour précédent"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="flex-1 flex flex-col items-center gap-1">
<span className="text-lg font-semibold text-slate-900 dark:text-slate-100 capitalize">
{formatDateLabel(currentDate)}
</span>
{!isToday(currentDate) && (
<span className="text-xs text-slate-400 dark:text-slate-500">
{format(currentDate, "EEEE d MMMM yyyy", { locale: fr })}
</span>
)}
</div>
<div className="flex items-center gap-1">
{!isToday(currentDate) && (
<button
onClick={goToToday}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-indigo-500 dark:text-indigo-400 transition-colors"
aria-label="Aujourd'hui"
title="Aujourd'hui"
>
<CalendarDays className="w-5 h-5" />
</button>
)}
<button
onClick={goToNextDay}
disabled={!canGoNext}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-400 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Jour suivant"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
</div>
{/* Note Editor / Viewer */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
{isDayLoading ? (
<div className="h-32 flex items-center justify-center">
<div className="w-5 h-5 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div>
) : isReadonly ? (
<div className="min-h-[8rem]">
{currentNote ? (
<p className="text-slate-700 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{currentNote.content}
</p>
) : (
<p className="text-slate-400 dark:text-slate-500 italic">
Aucune note pour ce jour.
</p>
)}
</div>
) : (
<>
<textarea
ref={textareaRef}
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
onBlur={handleBlur}
placeholder="Notes du jour..."
rows={6}
className="w-full resize-none bg-transparent text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 text-sm leading-relaxed focus:outline-none"
/>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<div className="flex items-center gap-2">
{currentNote && (
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 rounded-lg transition-colors disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer
</button>
)}
</div>
<div className="flex items-center gap-2">
{saveError && (
<span className="text-xs text-red-500">{saveError}</span>
)}
<button
onClick={handleSave}
disabled={isSaving || !hasUnsavedChanges || (!draftContent.trim() && !currentNote)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Save className="w-3.5 h-3.5" />
{isSaving ? "Sauvegarde…" : "Sauvegarder"}
</button>
</div>
</div>
{currentNote && (
<p className="text-xs text-slate-400 dark:text-slate-500 mt-2">
Dernière modification le{" "}
{format(new Date(currentNote.updatedAt), "d MMM yyyy à HH:mm", { locale: fr })}
{currentNote.user?.name ? ` par ${currentNote.user.name}` : ""}
</p>
)}
</>
)}
</div>
{/* Recent Notes */}
{recentDays.length > 0 && (
<div>
<h2 className="text-sm font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">
Notes récentes
</h2>
<div className="flex flex-col gap-3">
{recentDays.map(({ dateStr: ds, note }) => {
const d = parseISO(ds);
return (
<button
key={ds}
onClick={() => jumpToDate(ds)}
className="text-left bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 hover:border-indigo-300 dark:hover:border-indigo-600 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-indigo-600 dark:text-indigo-400 mb-1 capitalize">
{formatDateLabel(d)}
{!isYesterday(d) && !isToday(d) && (
<span className="text-slate-400 dark:text-slate-500 font-normal ml-1">
· {format(d, "d MMMM", { locale: fr })}
</span>
)}
</p>
<p className="text-sm text-slate-700 dark:text-slate-300 line-clamp-2 leading-relaxed">
{note.content}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-300 dark:text-slate-600 group-hover:text-indigo-400 flex-shrink-0 mt-0.5 transition-colors" />
</div>
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
}