6f2336ffae
- Remove quick-template feature from dashboard - Increase photo upload limit from 5MB to 30MB - Add combined diaper toggle (wet + stool in one save) to event modal - Show all event types in quick-add FAB and dashboard quick-log grid - Add OTHER generic event type for freeform notes (schema + migration) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
801 lines
35 KiB
TypeScript
801 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } 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";
|
|
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, FileText, ImageIcon } from "lucide-react";
|
|
import { useSession } from "next-auth/react";
|
|
|
|
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
|
{
|
|
label: "Alimentation & Soins",
|
|
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"],
|
|
},
|
|
{
|
|
label: "Sommeil & Activités",
|
|
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
|
|
},
|
|
{
|
|
label: "Santé",
|
|
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
|
|
},
|
|
{
|
|
label: "Autre",
|
|
types: ["OTHER"],
|
|
},
|
|
];
|
|
|
|
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 === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); }
|
|
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
|
|
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function formatElapsed(startedAt: string, now: Date): string {
|
|
const ms = Math.max(0, now.getTime() - new Date(startedAt).getTime());
|
|
const h = Math.floor(ms / 3600000);
|
|
const m = Math.floor((ms % 3600000) / 60000);
|
|
const s = Math.floor((ms % 60000) / 1000);
|
|
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function ActiveTimers({ events, onStop, babyName }: { events: Event[]; onStop: () => void; babyName?: string }) {
|
|
const [now, setNow] = useState(() => new Date());
|
|
const active = events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer);
|
|
|
|
useEffect(() => {
|
|
if (active.length === 0) return;
|
|
const id = setInterval(() => setNow(new Date()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [active.length]);
|
|
|
|
if (active.length === 0) return null;
|
|
|
|
return (
|
|
<div className="mb-6 space-y-2">
|
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">En cours</h2>
|
|
{active.map((ev) => {
|
|
const cfg = EVENT_CONFIG[ev.type];
|
|
return (
|
|
<div key={ev.id} className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${cfg.bgClass} ${cfg.borderClass}`}>
|
|
<div className={`w-9 h-9 rounded-lg bg-white/60 dark:bg-slate-900/40 flex items-center justify-center flex-shrink-0`}>
|
|
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</p>
|
|
{babyName && <span className="text-xs text-slate-400">{babyName}</span>}
|
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
|
Depuis {format(new Date(ev.startedAt), "HH:mm")}
|
|
</p>
|
|
</div>
|
|
<span className={`text-lg font-bold font-mono ${cfg.colorClass} tabular-nums`}>
|
|
{formatElapsed(ev.startedAt, now)}
|
|
</span>
|
|
<button
|
|
onClick={async () => {
|
|
await fetch(`/api/events/${ev.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ endedAt: new Date().toISOString() }),
|
|
});
|
|
onStop();
|
|
}}
|
|
className="ml-1 px-3 py-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-300 hover:border-red-300 hover:text-red-600 transition"
|
|
>
|
|
Terminer
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function QuickNoteWidget({ babyId }: { babyId: string }) {
|
|
const qc = useQueryClient();
|
|
const todayStr = format(new Date(), "yyyy-MM-dd");
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const { data: notes = [] } = useQuery<Array<{ id: string; content: string; user: { name: string } }>>({
|
|
queryKey: ["journal-today", babyId, todayStr],
|
|
queryFn: () => fetch(`/api/journal?babyId=${babyId}&date=${todayStr}`).then((r) => r.json()),
|
|
});
|
|
const todayNote = notes[0] ?? null;
|
|
|
|
function startEdit() {
|
|
setDraft(todayNote?.content ?? "");
|
|
setEditing(true);
|
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
}
|
|
|
|
async function save() {
|
|
if (!draft.trim()) return;
|
|
setSaving(true);
|
|
if (todayNote) {
|
|
await fetch(`/api/journal/${todayNote.id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: draft }),
|
|
});
|
|
} else {
|
|
await fetch("/api/journal", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ babyId, date: todayStr, content: draft }),
|
|
});
|
|
}
|
|
qc.invalidateQueries({ queryKey: ["journal-today", babyId] });
|
|
setEditing(false);
|
|
setSaving(false);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<FileText className="w-3.5 h-3.5 text-sky-600 dark:text-sky-400" />
|
|
<span className="text-xs font-semibold text-sky-700 dark:text-sky-400 uppercase tracking-wide">Note du jour</span>
|
|
</div>
|
|
{!editing && (
|
|
<button onClick={startEdit}
|
|
className="flex items-center gap-1 text-xs text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200 transition">
|
|
<Pencil className="w-3 h-3" />
|
|
{todayNote ? "Modifier" : "Ajouter"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{editing ? (
|
|
<div className="space-y-2">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-lg border border-sky-300 dark:border-sky-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-400 resize-none"
|
|
placeholder="Note rapide pour aujourd'hui..."
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button onClick={save} disabled={saving}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-sky-500 hover:bg-sky-600 text-white text-xs rounded-lg font-medium transition disabled:opacity-50">
|
|
<Check className="w-3 h-3" />
|
|
{saving ? "..." : "Enregistrer"}
|
|
</button>
|
|
<button onClick={() => setEditing(false)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 border border-sky-300 dark:border-sky-700 text-sky-700 dark:text-sky-400 text-xs rounded-lg transition hover:bg-sky-100 dark:hover:bg-sky-950">
|
|
<X className="w-3 h-3" />
|
|
Annuler
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : todayNote ? (
|
|
<p className="text-sm text-sky-900 dark:text-sky-100 whitespace-pre-wrap">{todayNote.content}</p>
|
|
) : (
|
|
<p className="text-xs text-sky-500 dark:text-sky-600 italic">Aucune note aujourd'hui.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HandoffWidget({ events, userId }: { events: Event[]; userId: string }) {
|
|
const [dismissed, setDismissed] = useState(true);
|
|
const [sinceTime, setSinceTime] = useState<Date | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!userId) return;
|
|
const key = `handoff_seen_${userId}`;
|
|
const stored = localStorage.getItem(key);
|
|
const now = new Date();
|
|
if (stored) {
|
|
const last = new Date(stored);
|
|
if (now.getTime() - last.getTime() > 2 * 3600 * 1000) {
|
|
setSinceTime(last);
|
|
setDismissed(false);
|
|
}
|
|
}
|
|
localStorage.setItem(key, now.toISOString());
|
|
}, [userId]);
|
|
|
|
if (dismissed || !sinceTime) return null;
|
|
|
|
const sinceEvents = events.filter((e) => new Date(e.startedAt) >= sinceTime!);
|
|
if (sinceEvents.length === 0) return null;
|
|
|
|
const feeds = sinceEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
|
const diapers = sinceEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
|
const sleepMs = sinceEvents
|
|
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
|
.reduce((acc, e) => acc + new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime(), 0);
|
|
const sleepH = Math.round((sleepMs / 3600000) * 10) / 10;
|
|
const elapsedH = Math.round((new Date().getTime() - sinceTime.getTime()) / 3600000);
|
|
|
|
return (
|
|
<div className="bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4 mb-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-400 uppercase tracking-wide">
|
|
Depuis votre dernière visite · {elapsedH}h
|
|
</span>
|
|
<button onClick={() => setDismissed(true)} className="text-emerald-400 hover:text-emerald-600 transition">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex gap-6">
|
|
{feeds > 0 && (
|
|
<div>
|
|
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{feeds}</div>
|
|
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">repas</div>
|
|
</div>
|
|
)}
|
|
{diapers > 0 && (
|
|
<div>
|
|
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{diapers}</div>
|
|
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">couches</div>
|
|
</div>
|
|
)}
|
|
{sleepMs > 0 && (
|
|
<div>
|
|
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{sleepH}h</div>
|
|
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">sommeil</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PinnedNoteWidget() {
|
|
const qc = useQueryClient();
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const { data } = useQuery<{ note: string | null; updatedAt: string | null; author: string | null }>({
|
|
queryKey: ["pinned-note"],
|
|
queryFn: () => fetch("/api/family/pinned-note").then((r) => r.json()),
|
|
});
|
|
|
|
function startEdit() {
|
|
setDraft(data?.note ?? "");
|
|
setEditing(true);
|
|
setTimeout(() => textareaRef.current?.focus(), 50);
|
|
}
|
|
|
|
async function save() {
|
|
setSaving(true);
|
|
await fetch("/api/family/pinned-note", {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ note: draft }),
|
|
});
|
|
qc.invalidateQueries({ queryKey: ["pinned-note"] });
|
|
setEditing(false);
|
|
setSaving(false);
|
|
}
|
|
|
|
const hasNote = !!data?.note;
|
|
|
|
return (
|
|
<div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-1.5">
|
|
<Pin className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
|
|
<span className="text-xs font-semibold text-amber-700 dark:text-amber-400 uppercase tracking-wide">Note épinglée</span>
|
|
</div>
|
|
{!editing && (
|
|
<button onClick={startEdit}
|
|
className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition">
|
|
<Pencil className="w-3 h-3" />
|
|
{hasNote ? "Modifier" : "Ajouter"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{editing ? (
|
|
<div className="space-y-2">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 rounded-lg border border-amber-300 dark:border-amber-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none"
|
|
placeholder="Message pour toute la famille..."
|
|
/>
|
|
<div className="flex gap-2">
|
|
<button onClick={save} disabled={saving}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500 hover:bg-amber-600 text-white text-xs rounded-lg font-medium transition disabled:opacity-50">
|
|
<Check className="w-3 h-3" />
|
|
{saving ? "..." : "Enregistrer"}
|
|
</button>
|
|
<button onClick={() => setEditing(false)}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 text-xs rounded-lg transition hover:bg-amber-100 dark:hover:bg-amber-950">
|
|
<X className="w-3 h-3" />
|
|
Annuler
|
|
</button>
|
|
{hasNote && (
|
|
<button onClick={async () => { await fetch("/api/family/pinned-note", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note: "" }) }); qc.invalidateQueries({ queryKey: ["pinned-note"] }); setEditing(false); }}
|
|
className="ml-auto flex items-center gap-1 text-xs text-red-400 hover:text-red-600 transition">
|
|
<PinOff className="w-3 h-3" />
|
|
Supprimer
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : hasNote ? (
|
|
<div>
|
|
<p className="text-sm text-amber-900 dark:text-amber-100 whitespace-pre-wrap">{data!.note}</p>
|
|
{data?.author && (
|
|
<p className="text-[10px] text-amber-500 dark:text-amber-500 mt-1.5">
|
|
{data.author}{data.updatedAt ? ` · ${format(new Date(data.updatedAt), "d MMM HH:mm", { locale: fr })}` : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-amber-500 dark:text-amber-600 italic">Aucune note. Cliquez sur Ajouter pour laisser un message à la famille.</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
export default function DashboardPage() {
|
|
const qc = useQueryClient();
|
|
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
|
const { selectedBaby, isLoading: familyLoading } = useBaby();
|
|
const { data: session } = useSession();
|
|
|
|
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(), selectedBaby?.name);
|
|
const id = setInterval(() => {
|
|
checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name);
|
|
}, 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;
|
|
|
|
const { data: medProfiles = [] } = useQuery<{ id: string; name: string; molecule: string | null; intervalHours: number; minIntervalHours: number | null }[]>({
|
|
queryKey: ["med-profiles"],
|
|
queryFn: () => fetch("/api/medication-profiles").then((r) => r.json()),
|
|
enabled: !!selectedBaby?.id,
|
|
});
|
|
|
|
const { data: lastIntakes = {} } = useQuery<Record<string, { startedAt: string }>>({
|
|
queryKey: ["last-intakes", selectedBaby?.id],
|
|
queryFn: () => fetch(`/api/medication-profiles/last-intakes?babyId=${selectedBaby!.id}`).then((r) => r.json()),
|
|
enabled: !!selectedBaby?.id,
|
|
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,
|
|
});
|
|
const { data: scheduleData } = useQuery({
|
|
queryKey: ["events-schedule", selectedBaby?.id],
|
|
queryFn: () =>
|
|
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=200&dateFrom=${new Date(Date.now() - 14 * 86400000).toISOString()}`).then((r) => r.json()),
|
|
enabled: !!selectedBaby?.id,
|
|
staleTime: 300_000,
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 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 };
|
|
})();
|
|
|
|
const schedulePrediction = (() => {
|
|
const evs: Event[] = scheduleData?.events ?? [];
|
|
if (evs.length < 10) return null;
|
|
|
|
function avgHourMin(times: number[]): string | null {
|
|
if (times.length === 0) return null;
|
|
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
|
|
const totalMin = Math.round(avgMs / 60000);
|
|
const h = Math.floor(totalMin / 60) % 24;
|
|
const m = totalMin % 60;
|
|
return `${h.toString().padStart(2, "0")}h${m.toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
const byDay: Record<string, Event[]> = {};
|
|
for (const e of evs) {
|
|
const day = e.startedAt.slice(0, 10);
|
|
if (!byDay[day]) byDay[day] = [];
|
|
byDay[day].push(e);
|
|
}
|
|
|
|
const napTimes: number[] = [];
|
|
const nightTimes: number[] = [];
|
|
const firstFeedTimes: number[] = [];
|
|
|
|
for (const [, dayEvs] of Object.entries(byDay)) {
|
|
const nap = dayEvs.find((e) => e.type === "NAP");
|
|
if (nap) {
|
|
const t = new Date(nap.startedAt);
|
|
napTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
|
}
|
|
|
|
const night = dayEvs.find((e) => e.type === "NIGHT_SLEEP");
|
|
if (night) {
|
|
const t = new Date(night.startedAt);
|
|
nightTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
|
}
|
|
|
|
const feeds = dayEvs
|
|
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
|
|
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
|
|
if (feeds.length > 0) {
|
|
const t = new Date(feeds[0].startedAt);
|
|
const minuteOfDay = t.getHours() * 60 + t.getMinutes();
|
|
if (minuteOfDay > 240) {
|
|
firstFeedTimes.push(minuteOfDay * 60000);
|
|
}
|
|
}
|
|
}
|
|
|
|
const nap = avgHourMin(napTimes);
|
|
const night = avgHourMin(nightTimes);
|
|
const firstFeed = avgHourMin(firstFeedTimes);
|
|
|
|
if (!nap && !night && !firstFeed) return null;
|
|
return { nap, night, firstFeed };
|
|
})();
|
|
|
|
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>
|
|
|
|
{/* Handoff summary */}
|
|
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
|
|
|
|
{/* Active timers */}
|
|
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} babyName={selectedBaby?.name} />
|
|
|
|
{/* Pinned note + Quick note */}
|
|
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
|
<PinnedNoteWidget />
|
|
<QuickNoteWidget babyId={selectedBaby.id} />
|
|
</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>
|
|
|
|
{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>
|
|
)}
|
|
|
|
{schedulePrediction && (
|
|
<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">Planning habituel</p>
|
|
<div className="flex flex-wrap gap-3">
|
|
{schedulePrediction.firstFeed && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">🍼</span>
|
|
<div>
|
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">1er repas</p>
|
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.firstFeed}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{schedulePrediction.nap && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">😴</span>
|
|
<div>
|
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">Sieste</p>
|
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.nap}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{schedulePrediction.night && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">🌙</span>
|
|
<div>
|
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">Coucher</p>
|
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.night}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] text-slate-300 dark:text-slate-600 mt-2">Moyenne sur 14 jours</p>
|
|
</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">
|
|
<span className="text-2xl">👶</span>
|
|
<div>
|
|
<p className="text-sm font-semibold text-indigo-800 dark:text-indigo-200">Bienvenue sur Grow !</p>
|
|
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-0.5">Commencez par enregistrer un repas, une couche ou un sommeil via les boutons ci-dessous.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick log */}
|
|
<div className="space-y-4">
|
|
{QUICK_LOG_GROUPS.map((group) => (
|
|
<div key={group.label}>
|
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2>
|
|
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
|
|
{group.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-indigo-200 dark:hover:border-indigo-700 hover:shadow-sm rounded-xl p-2.5 flex flex-col items-center gap-1.5 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>
|
|
|
|
{/* Medication status */}
|
|
{medProfiles.length > 0 && (() => {
|
|
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
|
if (activeMeds.length === 0) return null;
|
|
return (
|
|
<div className="mt-6">
|
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Médicaments</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">
|
|
{activeMeds.map((p) => {
|
|
const intake = lastIntakes[p.name];
|
|
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-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-orange-500" />
|
|
: overdue
|
|
? <AlertTriangle className="w-4 h-4 text-amber-500" />
|
|
: <CheckCircle2 className="w-4 h-4 text-green-500" />
|
|
}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{p.name}</p>
|
|
{p.molecule && <p className="text-xs text-slate-400 dark:text-slate-500">{p.molecule}</p>}
|
|
</div>
|
|
<div className="text-right flex-shrink-0">
|
|
{tooSoon ? (
|
|
<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>
|
|
)}
|
|
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-0.5 flex items-center gap-1 justify-end">
|
|
<Clock className="w-2.5 h-2.5" />{format(nextAt, "HH:mm")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Today's timeline */}
|
|
{todayEvents.length > 0 && (
|
|
<div className="mt-6">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
|
|
Aujourd'hui · {todayEvents.length} événement{todayEvents.length > 1 ? "s" : ""}
|
|
</h2>
|
|
<a href="/timeline" className="text-xs text-indigo-600 dark:text-indigo-400 hover:underline">
|
|
Voir tout →
|
|
</a>
|
|
</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">
|
|
{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>
|
|
{typeof ev.metadata?.photo === "string" && ev.metadata.photo && (
|
|
<ImageIcon className="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 flex-shrink-0" />
|
|
)}
|
|
<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>
|
|
);
|
|
}
|