feat: feeding heatmap, diaper streak, quick note, handoff summary, weight percentile alert, growth curve labels

- stats: 7×24 feed density heatmap (Mon–Sun × 24h, indigo intensity, dark mode)
- stats: diaper streak card — current consecutive days ≥4 couches + best streak for period
- dashboard: QuickNoteWidget — inline journal note (sky card) without leaving dashboard
- dashboard: HandoffWidget — localStorage "since last visit" digest (feeds/diapers/sleep) shown after 2h gap
- growth: replace S0/S1/S2 x-axis with human labels (Naiss., 1sem, 2sem, 1m, 2m … 1 an)
- growth: tooltip shows age in full words (e.g. "3 mois 1 sem.")
- cron: POST /api/cron/weight-percentile-alert — alerts when baby weight exits configurable WHO band (weight_percentile_min / weight_percentile_max SystemConfig keys, deduped per measurement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:05:04 +02:00
parent b417eb648c
commit e27f42942c
5 changed files with 419 additions and 15 deletions
+163 -3
View File
@@ -9,7 +9,8 @@ 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, Trash2 } from "lucide-react";
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2, FileText } from "lucide-react";
import { useSession } from "next-auth/react";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
{
@@ -69,6 +70,160 @@ function getEventSummary(event: Event): string {
return "";
}
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&apos;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);
@@ -277,6 +432,7 @@ 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],
@@ -353,9 +509,13 @@ export default function DashboardPage() {
</div>
</div>
{/* Pinned note */}
<div className="mb-6">
{/* Handoff summary */}
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
{/* 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 */}