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:
+6
-6
@@ -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)
|
||||||
|
|||||||
@@ -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'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 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user