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
+6 -6
View File
@@ -65,15 +65,15 @@
🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.) 🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.)
🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers) 🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers)
🔲 Data retention policy — auto-archive events older than N months 🔲 Data retention policy — auto-archive events older than N months
🔲 Offline support / PWA install prompt Offline support / PWA install prompt — service worker via next-pwa, PNG icons, apple-touch-icon, offline fallback page
🔲 Multi-language UI (FR/EN toggle) 🔲 Multi-language UI (FR/EN toggle)
✅ Daily digest push notification — /api/cron/daily-digest: last feed, last diaper, sleep total, next medication due ✅ Daily digest push notification — /api/cron/daily-digest: last feed, last diaper, sleep total, next medication due
✅ Baby photo timeline — /photos, grid by day, full-screen lightbox with prev/next nav ✅ Baby photo timeline — /photos, grid by day, full-screen lightbox with prev/next nav
✅ Milk stock expiry alert push — /api/cron/milk-expiry: lots expiring within 24h, grouped by family ✅ Milk stock expiry alert push — /api/cron/milk-expiry: lots expiring within 24h, grouped by family
✅ Batch delete events — "Sélectionner" mode in timeline, checkboxes per row, confirm + bulk delete ✅ Batch delete events — "Sélectionner" mode in timeline, checkboxes per row, confirm + bulk delete
✅ Event duration history chart — avg NAP/NIGHT_SLEEP duration per day, dual-line chart in stats ✅ Event duration history chart — avg NAP/NIGHT_SLEEP duration per day, dual-line chart in stats
🔲 Feeding session heatmap — 7×24 grid showing feed density by hour/day of week Feeding session heatmap — 7×24 grid showing feed density by hour/day of week, in stats page
🔲 Diaper tracker streak — consecutive days with at least N diapers (regression alert) Diaper tracker streak — consecutive days with ≥4 couches; current + best streak in stats
🔲 Quick note from dashboard — one-tap free text note without navigating to /notes Quick note from dashboard — inline journal note widget (sky card) without leaving dashboard
🔲 Caregiver handoff summary — auto-generated "since last login" digest shown on dashboard login Caregiver handoff summary — localStorage-based "since last visit" digest on dashboard (feeds/diapers/sleep, shown after 2h gap)
🔲 Weight percentile push alert — notify when weight drops below/above a configurable WHO percentile band Weight percentile push alert — /api/cron/weight-percentile-alert: alerts when weight outside configurable WHO band (weight_percentile_min / weight_percentile_max SystemConfig keys)
+163 -3
View File
@@ -9,7 +9,8 @@ import { useBaby } from "@/contexts/baby-context";
import { checkFeedAlert, getThresholds } from "@/lib/notifications"; import { checkFeedAlert, getThresholds } from "@/lib/notifications";
import { format, isToday } from "date-fns"; import { format, isToday } from "date-fns";
import { fr } from "date-fns/locale"; 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[] }[] = [ const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
{ {
@@ -69,6 +70,160 @@ function getEventSummary(event: Event): string {
return ""; 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() { function PinnedNoteWidget() {
const qc = useQueryClient(); const qc = useQueryClient();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
@@ -277,6 +432,7 @@ export default function DashboardPage() {
const qc = useQueryClient(); const qc = useQueryClient();
const [activeModal, setActiveModal] = useState<EventType | null>(null); const [activeModal, setActiveModal] = useState<EventType | null>(null);
const { selectedBaby, isLoading: familyLoading } = useBaby(); const { selectedBaby, isLoading: familyLoading } = useBaby();
const { data: session } = useSession();
const { data: eventsData } = useQuery({ const { data: eventsData } = useQuery({
queryKey: ["events", selectedBaby?.id], queryKey: ["events", selectedBaby?.id],
@@ -353,9 +509,13 @@ export default function DashboardPage() {
</div> </div>
</div> </div>
{/* Pinned note */} {/* Handoff summary */}
<div className="mb-6"> <HandoffWidget events={events} userId={session?.user?.id ?? ""} />
{/* Pinned note + Quick note */}
<div className="grid sm:grid-cols-2 gap-4 mb-6">
<PinnedNoteWidget /> <PinnedNoteWidget />
<QuickNoteWidget babyId={selectedBaby.id} />
</div> </div>
{/* Today's summary counters */} {/* Today's summary counters */}
+24 -6
View File
@@ -101,6 +101,24 @@ function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"; const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
function fmtAgeWeeks(weeks: number): string {
if (weeks === 0) return "Naiss.";
if (weeks < 4) return `${weeks}sem`;
const months = Math.round(weeks / 4.345);
if (months >= 12) return "1 an";
return `${months}m`;
}
function fmtAgeLabel(weeks: number): string {
if (weeks === 0) return "Naissance";
if (weeks < 4) return `${weeks} semaine${weeks > 1 ? "s" : ""}`;
const months = Math.floor(weeks / 4.345);
const remWeeks = Math.round(weeks - months * 4.345);
if (months >= 12) return "1 an";
if (remWeeks > 0) return `${months} mois ${remWeeks} sem.`;
return `${months} mois`;
}
function WeightTooltip(props: Record<string, unknown> & { style: React.CSSProperties }) { function WeightTooltip(props: Record<string, unknown> & { style: React.CSSProperties }) {
const { active, payload, label, style } = props as { const { active, payload, label, style } = props as {
active?: boolean; active?: boolean;
@@ -113,7 +131,7 @@ function WeightTooltip(props: Record<string, unknown> & { style: React.CSSProper
if (!baby) return null; if (!baby) return null;
return ( return (
<div style={style} className="px-3 py-2 rounded-lg text-xs"> <div style={style} className="px-3 py-2 rounded-lg text-xs">
<p className="text-slate-400 mb-1">Semaine {label}</p> <p className="text-slate-400 mb-1">{fmtAgeLabel(label ?? 0)}</p>
<p className="font-semibold text-indigo-500">Bébé : {baby.value.toFixed(3)} kg</p> <p className="font-semibold text-indigo-500">Bébé : {baby.value.toFixed(3)} kg</p>
</div> </div>
); );
@@ -341,7 +359,7 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={weightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} /> <Tooltip content={(props) => <WeightTooltip {...props} style={tooltipStyle} />} />
{WHO_CURVE_KEYS.map((key, i) => ( {WHO_CURVE_KEYS.map((key, i) => (
@@ -361,9 +379,9 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={heightData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "Taille"]} contentStyle={tooltipStyle} /> <Tooltip formatter={(val) => [`${val} cm`, "Taille"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} /> <Line dataKey="value" stroke="#0ea5e9" strokeWidth={2.5} dot={{ r: 4, fill: "#0ea5e9", strokeWidth: 2, stroke: "#fff" }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -377,9 +395,9 @@ export default function GrowthPage() {
<ResponsiveContainer width="100%" height={160}> <ResponsiveContainer width="100%" height={160}>
<LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}> <LineChart data={headData} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} /> <CartesianGrid strokeDasharray="3 3" stroke={gridStroke} />
<XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={(v) => `S${v}`} axisLine={false} tickLine={false} /> <XAxis dataKey="ageWeeks" tick={{ fontSize: 10, fill: "#94a3b8" }} tickFormatter={fmtAgeWeeks} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} /> <YAxis tick={{ fontSize: 10, fill: "#94a3b8" }} axisLine={false} tickLine={false} domain={["auto", "auto"]} />
<Tooltip formatter={(val) => [`${val} cm`, "PC"]} contentStyle={tooltipStyle} /> <Tooltip formatter={(val) => [`${val} cm`, "PC"]} labelFormatter={(v) => fmtAgeLabel(Number(v))} contentStyle={tooltipStyle} />
<Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} /> <Line dataKey="value" stroke="#10b981" strokeWidth={2.5} dot={{ r: 4, fill: "#10b981", strokeWidth: 2, stroke: "#fff" }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
+109
View File
@@ -137,6 +137,91 @@ function useDarkMode() {
return isDark; return isDark;
} }
function computeDiaperStreak(events: Event[], days: number): { current: number; best: number; threshold: number } {
const THRESHOLD = 4;
const counts = new Map<string, number>();
for (const e of events) {
if (e.type !== "DIAPER_WET" && e.type !== "DIAPER_STOOL") continue;
const key = format(new Date(e.startedAt), "yyyy-MM-dd");
counts.set(key, (counts.get(key) ?? 0) + 1);
}
let current = 0;
for (let i = 0; i < days; i++) {
const key = format(subDays(new Date(), i), "yyyy-MM-dd");
if ((counts.get(key) ?? 0) >= THRESHOLD) current++;
else break;
}
let best = 0, streak = 0;
for (let i = days - 1; i >= 0; i--) {
const key = format(subDays(new Date(), i), "yyyy-MM-dd");
if ((counts.get(key) ?? 0) >= THRESHOLD) { streak++; if (streak > best) best = streak; }
else streak = 0;
}
return { current, best, threshold: THRESHOLD };
}
function FeedHeatmap({ events, isDark }: { events: Event[]; isDark: boolean }) {
const DOW_LABELS = ["Lun", "Mar", "Mer", "Jeu", "Ven", "Sam", "Dim"];
function toDowIndex(jsDow: number): number { return jsDow === 0 ? 6 : jsDow - 1; }
const grid: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));
for (const e of events) {
if (e.type !== "BREASTFEED" && e.type !== "BOTTLE") continue;
const d = new Date(e.startedAt);
grid[toDowIndex(d.getDay())][d.getHours()]++;
}
const maxVal = Math.max(...grid.flat(), 1);
const cellBg = (count: number): string => {
if (count === 0) return isDark ? "rgba(51,65,85,0.4)" : "rgb(241,245,249)";
const intensity = 0.15 + (count / maxVal) * 0.85;
return isDark ? `rgba(99,102,241,${intensity})` : `rgba(79,70,229,${intensity})`;
};
return (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-0.5">Repas carte thermique</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-3">Fréquence par heure et jour de semaine</p>
<div className="overflow-x-auto">
<div className="min-w-[320px]">
<div className="flex mb-1 ml-8">
{Array.from({ length: 24 }, (_, h) => (
<div key={h} className="flex-1 text-center">
{h % 6 === 0 && (
<span className="text-[9px] text-slate-400 dark:text-slate-500">{h}h</span>
)}
</div>
))}
</div>
{grid.map((row, dowIdx) => (
<div key={dowIdx} className="flex items-center gap-0.5 mb-0.5">
<span className="text-[10px] w-8 text-right pr-1.5 text-slate-400 dark:text-slate-500 flex-shrink-0">{DOW_LABELS[dowIdx]}</span>
<div className="flex gap-0.5 flex-1">
{row.map((count, hour) => (
<div
key={hour}
title={`${DOW_LABELS[dowIdx]} ${hour.toString().padStart(2, "0")}h — ${count} repas`}
className="flex-1 h-5 rounded-[3px]"
style={{ backgroundColor: cellBg(count) }}
/>
))}
</div>
</div>
))}
</div>
</div>
<div className="flex items-center gap-1.5 mt-3 justify-end">
<span className="text-[10px] text-slate-400 dark:text-slate-500 mr-1">Moins</span>
{[0.15, 0.4, 0.65, 0.85, 1.0].map((v) => (
<div key={v} className="w-3 h-3 rounded-[3px]"
style={{ backgroundColor: isDark ? `rgba(99,102,241,${v})` : `rgba(79,70,229,${v})` }} />
))}
<span className="text-[10px] text-slate-400 dark:text-slate-500 ml-1">Plus</span>
</div>
</div>
);
}
function SleepTimeline({ events }: { events: Event[] }) { function SleepTimeline({ events }: { events: Event[] }) {
const WIN_START_H = 19; const WIN_START_H = 19;
const WIN_HOURS = 16; // 19:00 → 11:00 next day const WIN_HOURS = 16; // 19:00 → 11:00 next day
@@ -257,6 +342,8 @@ export default function StatsPage() {
const sleepStats = computeSleepStats(events, days); const sleepStats = computeSleepStats(events, days);
const feedingStats = computeFeedingStats(events); const feedingStats = computeFeedingStats(events);
const diaperStreak = computeDiaperStreak(events, days);
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString()); const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length; const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
const todayDiapers = today.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length; const todayDiapers = today.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
@@ -339,6 +426,28 @@ export default function StatsPage() {
<SleepTimeline events={events} /> <SleepTimeline events={events} />
<FeedHeatmap events={events} isDark={isDark} />
{/* Diaper streak */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">Streak couches</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Jours consécutifs avec {diaperStreak.threshold} couches</p>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Streak actuel</p>
<p className="text-2xl font-bold text-amber-600 leading-tight">
{diaperStreak.current} <span className="text-base font-normal text-slate-400">jour{diaperStreak.current !== 1 ? "s" : ""}</span>
</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Meilleur ({periodSubtitle(days)})</p>
<p className="text-2xl font-bold text-amber-500 leading-tight">
{diaperStreak.best} <span className="text-base font-normal text-slate-400">jour{diaperStreak.best !== 1 ? "s" : ""}</span>
</p>
</div>
</div>
</div>
{/* Feeding summary */} {/* Feeding summary */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé des repas</p> <p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-3">Résumé des repas</p>
@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendPushover } from "@/lib/push-notify";
import { getAgeInWeeks, interpolatePercentile, PERCENTILE_LABELS } from "@/lib/who-percentiles";
// Call daily with:
// POST /api/cron/weight-percentile-alert
// Authorization: Bearer <CRON_SECRET>
async function getCronSecret(): Promise<string> {
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
return row?.value || process.env.CRON_SECRET || "";
}
async function getPercentileConfig(): Promise<{ minLabel: string; maxLabel: string }> {
const [minRow, maxRow] = await Promise.all([
prisma.systemConfig.findUnique({ where: { key: "weight_percentile_min" } }),
prisma.systemConfig.findUnique({ where: { key: "weight_percentile_max" } }),
]);
return {
minLabel: minRow?.value ?? "P3",
maxLabel: maxRow?.value ?? "P97",
};
}
function labelToIndex(label: string): number {
const idx = PERCENTILE_LABELS.indexOf(label);
return idx === -1 ? 0 : idx;
}
function getPercentileBand(ageWeeks: number, weight: number): string {
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
if (weight < values[0]) return "< P3";
for (let i = 0; i < values.length - 1; i++) {
if (weight >= values[i] && weight < values[i + 1]) {
return `${PERCENTILE_LABELS[i]}${PERCENTILE_LABELS[i + 1]}`;
}
}
return "> P97";
}
export async function POST(req: Request) {
const secret = await getCronSecret();
if (secret) {
const authHeader = req.headers.get("authorization") ?? "";
if (authHeader !== `Bearer ${secret}`) {
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
}
}
const { minLabel, maxLabel } = await getPercentileConfig();
const minIdx = labelToIndex(minLabel);
const maxIdx = labelToIndex(maxLabel);
const families = await prisma.family.findMany({
include: {
babies: {
include: {
growthLogs: {
where: { weight: { not: null } },
orderBy: { date: "desc" },
take: 1,
},
},
},
users: {
where: { pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
},
},
});
const alerts: { babyName: string; band: string; weight: number }[] = [];
for (const family of families) {
if (family.users.length === 0) continue;
for (const baby of family.babies) {
const log = baby.growthLogs[0];
if (!log?.weight) continue;
const alertSentKey = `weight_percentile_alert_last_${baby.id}`;
const lastSentRow = await prisma.systemConfig.findUnique({ where: { key: alertSentKey } });
if (lastSentRow?.value === log.id) continue;
const ageWeeks = getAgeInWeeks(new Date(baby.birthDate), new Date(log.date));
if (ageWeeks < 0 || ageWeeks > 52) continue;
const minThreshold = interpolatePercentile(ageWeeks, minIdx);
const maxThreshold = interpolatePercentile(ageWeeks, maxIdx);
if (minThreshold === null || maxThreshold === null) continue;
const outsideBounds = log.weight < minThreshold || log.weight > maxThreshold;
if (!outsideBounds) continue;
const band = getPercentileBand(ageWeeks, log.weight);
const direction = log.weight < minThreshold ? `en dessous de ${minLabel}` : `au dessus de ${maxLabel}`;
const message = `Le poids de ${baby.name} (${log.weight} kg) est ${direction} — percentile ${band}. Consultez la courbe de croissance.`;
for (const user of family.users) {
if (user.pushoverUserKey) {
await sendPushover(user.pushoverUserKey, message, `⚖️ Alerte poids — ${baby.name}`);
}
}
await prisma.systemConfig.upsert({
where: { key: alertSentKey },
update: { value: log.id },
create: { key: alertSentKey, value: log.id },
});
alerts.push({ babyName: baby.name, band, weight: log.weight });
}
}
return NextResponse.json({ ok: true, alertsSent: alerts.length, alerts, minLabel, maxLabel });
}