feat: teeth tracker, feeding analysis, sidebar groups, timeline polish

Features:
- Teeth tracker: Tooth model + migration, API routes (GET/POST upsert/DELETE),
  page at /teeth with 20-tooth grid, progress bar, inline mark/unmark flow
- Dashboard feeding analysis: avg interval + last feed + next expected time widget
- Fix dashboard baby?.name crash (selectedBaby?.name)

UI/UX:
- Sidebar: grouped into Suivi / Santé / Vie du bébé / Outils sections
- Nav: active highlight works on child routes (startsWith)
- Timeline: filter chips sticky on mobile; skeleton cards replace spinner
- Stats: 500-event limit warning banner
- Medications: 3 states — "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber)
- Notes: skip auto-save on empty/whitespace blur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 21:51:32 +02:00
parent 8bb048ff56
commit cd3efa98fc
11 changed files with 596 additions and 25 deletions
+79 -5
View File
@@ -511,9 +511,9 @@ export default function DashboardPage() {
useEffect(() => {
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name);
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name);
const id = setInterval(() => {
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name);
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name);
}, 5 * 60 * 1000);
return () => clearInterval(id);
}, [events]);
@@ -540,6 +540,21 @@ export default function DashboardPage() {
refetchInterval: 30_000,
});
const { data: feedData } = useQuery({
queryKey: ["events-feeds", selectedBaby?.id],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
staleTime: 60_000,
});
const { data: bottleData } = useQuery({
queryKey: ["events-bottles", selectedBaby?.id],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BOTTLE`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
staleTime: 60_000,
});
if (familyLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
@@ -559,6 +574,26 @@ export default function DashboardPage() {
);
}
// Feed pattern analysis
const allFeeds: Event[] = [
...(feedData?.events ?? []),
...(bottleData?.events ?? []),
].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
const feedAnalysis = (() => {
const recent = allFeeds.slice(0, 20);
if (recent.length < 2) return null;
const times = recent.map((e) => new Date(e.startedAt).getTime()).sort((a, b) => b - a);
const intervals = times.slice(0, -1).map((t, i) => t - times[i + 1]);
const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const avgH = Math.floor(avgMs / 3600000);
const avgM = Math.round((avgMs % 3600000) / 60000);
const lastFeedTime = new Date(times[0]);
const nextExpected = new Date(times[0] + avgMs);
const untilNextMs = nextExpected.getTime() - Date.now();
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
})();
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Page header */}
@@ -601,6 +636,40 @@ export default function DashboardPage() {
})}
</div>
{feedAnalysis && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Repas</p>
<div className="grid grid-cols-3 gap-3 text-center">
<div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Intervalle moy.</p>
<p className="text-lg font-bold text-rose-600 dark:text-rose-400 font-mono">
{feedAnalysis.avgH}h{feedAnalysis.avgM.toString().padStart(2, "0")}
</p>
</div>
<div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Dernier repas</p>
<p className="text-lg font-bold text-slate-700 dark:text-slate-200 font-mono">
{format(feedAnalysis.lastFeedTime, "HH:mm")}
</p>
</div>
<div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">Prochain estimé</p>
<p className={`text-lg font-bold font-mono ${feedAnalysis.untilNextMs < 0 ? "text-amber-500" : "text-indigo-600 dark:text-indigo-400"}`}>
{format(feedAnalysis.nextExpected, "HH:mm")}
</p>
{feedAnalysis.untilNextMs > 0 && (
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-0.5">
dans {Math.floor(feedAnalysis.untilNextMs / 3600000)}h{Math.floor((feedAnalysis.untilNextMs % 3600000) / 60000).toString().padStart(2, "0")}
</p>
)}
{feedAnalysis.untilNextMs < 0 && (
<p className="text-[10px] text-amber-500 dark:text-amber-400 mt-0.5">dépassé</p>
)}
</div>
</div>
</div>
)}
{/* Empty state for new users */}
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
@@ -658,14 +727,17 @@ export default function DashboardPage() {
const nextAt = new Date(new Date(intake.startedAt).getTime() + p.intervalHours * 3600000);
const waitMs = nextAt.getTime() - Date.now();
const tooSoon = waitMs > 0;
const overdue = !tooSoon && waitMs < -3600000;
const h = Math.floor(Math.abs(waitMs) / 3600000);
const m = Math.floor((Math.abs(waitMs) % 3600000) / 60000);
const timeLabel = h > 0 ? `${h}h${m.toString().padStart(2, "0")}` : `${m}min`;
return (
<div key={p.id} className="flex items-center gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${tooSoon ? "bg-red-50 dark:bg-red-950" : "bg-green-50 dark:bg-green-950"}`}>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${tooSoon ? "bg-orange-50 dark:bg-orange-950" : overdue ? "bg-amber-50 dark:bg-amber-950" : "bg-green-50 dark:bg-green-950"}`}>
{tooSoon
? <AlertTriangle className="w-4 h-4 text-red-500" />
? <AlertTriangle className="w-4 h-4 text-orange-500" />
: overdue
? <AlertTriangle className="w-4 h-4 text-amber-500" />
: <CheckCircle2 className="w-4 h-4 text-green-500" />
}
</div>
@@ -675,7 +747,9 @@ export default function DashboardPage() {
</div>
<div className="text-right flex-shrink-0">
{tooSoon ? (
<p className="text-xs font-semibold text-red-600 dark:text-red-400">Dans {timeLabel}</p>
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400">Dans {timeLabel}</p>
) : overdue ? (
<p className="text-xs font-semibold text-amber-600 dark:text-amber-400">En retard de {timeLabel}</p>
) : (
<p className="text-xs font-semibold text-green-600 dark:text-green-400">Disponible</p>
)}
+1 -3
View File
@@ -162,11 +162,9 @@ export default function NotesPage() {
async function handleBlur() {
if (isReadonly) return;
const trimmed = draftContent.trim();
// Only auto-save if content changed from what's stored
if (!trimmed) return;
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();
}
+7
View File
@@ -338,6 +338,7 @@ export default function StatsPage() {
});
const events: Event[] = eventsData?.events ?? [];
const hitLimit = events.length >= 500;
const weeklyData = buildWeeklyData(events, days);
const durationTrend = buildDurationTrend(events, Math.min(days, 30));
const feedInterval = computeFeedInterval(events);
@@ -377,6 +378,12 @@ export default function StatsPage() {
</div>
</div>
{hitLimit && (
<div className="mb-4 px-4 py-2.5 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-xl text-xs text-amber-700 dark:text-amber-300">
Données limitées aux 500 derniers événements de la période les graphiques peuvent être incomplets.
</div>
)}
{/* Today summary */}
<div className="grid grid-cols-3 gap-2 md:gap-3 mb-6">
{[
+317
View File
@@ -0,0 +1,317 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Check, Trash2, X } from "lucide-react";
interface Tooth {
id: string;
babyId: string;
code: string;
appearedAt: string;
notes: string | null;
}
const UPPER_CODES = ["u5l", "u4l", "u3l", "u2l", "u1l", "u1r", "u2r", "u3r", "u4r", "u5r"];
const LOWER_CODES = ["l5l", "l4l", "l3l", "l2l", "l1l", "l1r", "l2r", "l3r", "l4r", "l5r"];
const TOOTH_NAMES: Record<string, string> = {
"1": "Incisive centrale",
"2": "Incisive latérale",
"3": "Canine",
"4": "Première molaire",
"5": "Deuxième molaire",
};
const ERUPTION_AGES: Record<string, string> = {
"1": "612 mois",
"2": "916 mois",
"3": "1623 mois",
"4": "1418 mois",
"5": "2331 mois",
};
function getToothNumber(code: string): string {
// code is like "u1l", "l3r" — digit is at index 1
return code[1];
}
function getToothLabel(code: string): string {
const num = getToothNumber(code);
const jaw = code[0] === "u" ? "sup." : "inf.";
const side = code[2] === "l" ? "gauche" : "droite";
return `${TOOTH_NAMES[num]} ${jaw} ${side}`;
}
const inputCls =
"w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
type ActiveState =
| { type: "form"; code: string; date: string; notes: string }
| { type: "detail"; code: string }
| null;
export default function TeethPage() {
const qc = useQueryClient();
const { selectedBaby: baby } = useBaby();
const [active, setActive] = useState<ActiveState>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const { data: teeth = [] } = useQuery<Tooth[]>({
queryKey: ["teeth", baby?.id],
queryFn: () =>
fetch(`/api/teeth?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id,
});
const appearedCodes = new Set(teeth.map((t) => t.code));
const count = appearedCodes.size;
function getToothData(code: string): Tooth | undefined {
return teeth.find((t) => t.code === code);
}
function handleToothClick(code: string) {
if (appearedCodes.has(code)) {
setActive(active?.type === "detail" && active.code === code ? null : { type: "detail", code });
} else {
const today = format(new Date(), "yyyy-MM-dd");
setActive(
active?.type === "form" && active.code === code
? null
: { type: "form", code, date: today, notes: "" }
);
}
}
async function handleSave(e: React.FormEvent) {
e.preventDefault();
if (!baby || active?.type !== "form") return;
setSaving(true);
await fetch("/api/teeth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: baby.id,
code: active.code,
appearedAt: new Date(active.date).toISOString(),
notes: active.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
setSaving(false);
setActive(null);
}
async function handleDelete(code: string) {
if (!baby) return;
setDeleting(code);
await fetch(`/api/teeth/${code}?babyId=${baby.id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["teeth", baby.id] });
setDeleting(null);
setActive(null);
}
function ToothSquare({ code }: { code: string }) {
const appeared = appearedCodes.has(code);
const num = getToothNumber(code);
const isActive = active !== null && active.code === code;
return (
<button
onClick={() => handleToothClick(code)}
title={`${getToothLabel(code)}${ERUPTION_AGES[num]}`}
className={`
relative aspect-square w-full rounded-xl border-2 transition-all duration-150 flex flex-col items-center justify-center gap-0.5 text-xs font-semibold
${appeared
? "bg-emerald-50 dark:bg-emerald-950 border-emerald-400 dark:border-emerald-600 text-emerald-700 dark:text-emerald-300"
: "bg-slate-100 dark:bg-slate-800 border-dashed border-slate-300 dark:border-slate-600 text-slate-400 dark:text-slate-500"
}
${isActive ? "ring-2 ring-offset-1 ring-emerald-500 dark:ring-emerald-400" : "hover:border-emerald-400 dark:hover:border-emerald-500"}
`}
>
{appeared ? (
<>
<Check className="w-4 h-4" />
<span className="text-[10px] leading-tight">{num}</span>
</>
) : (
<span className="text-sm">{num}</span>
)}
</button>
);
}
const activeToothLabel = active ? getToothLabel(active.code) : "";
const activeToothNum = active ? getToothNumber(active.code) : "";
return (
<div className="p-4 md:p-8 pb-28 md:pb-8 max-w-lg mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Dents</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
</div>
{/* Progress */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
{count} / 20 dents apparues
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{Math.round((count / 20) * 100)} %
</span>
</div>
<div className="h-2.5 bg-slate-100 dark:bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-500"
style={{ width: `${(count / 20) * 100}%` }}
/>
</div>
</div>
{/* Diagram */}
<div className="bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-700 p-4 space-y-3">
{/* Upper jaw label */}
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
Mâchoire supérieure
</p>
<div className="grid grid-cols-10 gap-1.5">
{UPPER_CODES.map((code) => (
<ToothSquare key={code} code={code} />
))}
</div>
<div className="border-t border-dashed border-slate-200 dark:border-slate-700" />
<div className="grid grid-cols-10 gap-1.5">
{LOWER_CODES.map((code) => (
<ToothSquare key={code} code={code} />
))}
</div>
{/* Lower jaw label */}
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-600 text-center">
Mâchoire inférieure
</p>
</div>
{/* Legend */}
<div className="mt-3 flex items-center gap-4 text-xs text-slate-500 dark:text-slate-400">
<span className="flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded border-2 border-dashed border-slate-300 dark:border-slate-600 inline-block" />
Non apparue
</span>
<span className="flex items-center gap-1.5">
<span className="w-3.5 h-3.5 rounded border-2 border-emerald-400 bg-emerald-50 dark:bg-emerald-950 inline-block" />
Apparue
</span>
</div>
{/* Inline panel for form or detail */}
{active && (
<div className="mt-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">
{activeToothLabel}
</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">
Éruption typique : {ERUPTION_AGES[activeToothNum]}
</p>
</div>
<button
onClick={() => setActive(null)}
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<X className="w-4 h-4" />
</button>
</div>
{active.type === "form" ? (
<form onSubmit={handleSave} className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
Date d&apos;apparition <span className="text-red-400">*</span>
</label>
<input
type="date"
required
value={active.date}
onChange={(e) =>
setActive((prev) =>
prev?.type === "form" ? { ...prev, date: e.target.value } : prev
)
}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1">
Notes <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="text"
value={active.notes}
onChange={(e) =>
setActive((prev) =>
prev?.type === "form" ? { ...prev, notes: e.target.value } : prev
)
}
placeholder="Observations..."
className={inputCls}
/>
</div>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => setActive(null)}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition"
>
Annuler
</button>
<button
type="submit"
disabled={saving}
className="flex-1 py-2 bg-emerald-600 text-white rounded-lg text-sm font-medium hover:bg-emerald-700 transition disabled:opacity-60"
>
{saving ? "..." : "Enregistrer"}
</button>
</div>
</form>
) : (
(() => {
const tooth = getToothData(active.code);
if (!tooth) return null;
return (
<div className="space-y-2">
<p className="text-sm text-slate-700 dark:text-slate-200">
Apparue le{" "}
<span className="font-semibold">
{format(new Date(tooth.appearedAt), "d MMMM yyyy", { locale: fr })}
</span>
</p>
{tooth.notes && (
<p className="text-sm text-slate-500 dark:text-slate-400">{tooth.notes}</p>
)}
<button
onClick={() => handleDelete(active.code)}
disabled={deleting === active.code}
className="flex items-center gap-2 mt-2 px-3 py-1.5 rounded-lg text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
{deleting === active.code ? "Suppression..." : "Supprimer"}
</button>
</div>
);
})()
)}
</div>
)}
</div>
);
}
+21 -3
View File
@@ -160,7 +160,8 @@ export default function TimelinePage() {
</button>
</div>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<div className="sticky top-0 z-10 bg-white dark:bg-slate-900 -mx-4 px-4 py-2 border-b border-slate-100 dark:border-slate-800">
<div className="flex gap-2 overflow-x-auto no-scrollbar">
<button
onClick={() => handleFilterChange("")}
className={`flex-shrink-0 px-3 py-1.5 rounded-full text-xs font-medium transition border ${
@@ -182,6 +183,7 @@ export default function TimelinePage() {
</button>
))}
</div>
</div>
</div>
{selectMode && (
@@ -210,8 +212,24 @@ export default function TimelinePage() {
)}
{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 className="space-y-6">
{[1, 2, 3].map((g) => (
<div key={g}>
<div className="h-3 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse mb-3" />
<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">
{[1, 2, 3, 4].map((r) => (
<div key={r} className="flex items-center gap-3 px-4 py-3">
<div className="w-8 h-8 rounded-lg bg-slate-200 dark:bg-slate-700 animate-pulse flex-shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-28 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="h-2.5 w-16 bg-slate-100 dark:bg-slate-800 rounded animate-pulse" />
</div>
<div className="h-3 w-10 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
</div>
))}
</div>
</div>
))}
</div>
)}