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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user