diff --git a/FEATURES.md b/FEATURES.md
index 0159cec..a0e8979 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -54,18 +54,16 @@
## Feature ideas / backlog
-🔲 Offline support / PWA install prompt — service worker for offline event logging
-🔲 Multi-language UI (FR/EN toggle) — see also pending Bilingual UI
✅ Weekly summary cron — POST /api/cron/weekly-summary, 7-day digest per baby (feedings, sleep, diapers, weight), Pushover to family; auth via Bearer cron_secret
✅ Baby activities log — BATH, WALK, TUMMY_TIME event types (timer + duration)
✅ Pinned family note — shared editable note on dashboard; /api/family/pinned-note
✅ Event templates — named quick-log presets; create/delete from dashboard; /api/event-templates
✅ Maintenance mode banner — layout reads SystemConfig; amber banner for users, indicator for superadmin
-🔲 Growth alert: no weight log in 30 days → push notification
+✅ Growth alert: no weight log in 30 days → push notification (configurable via growth_alert_days config key)
+✅ Sleep log chart — visual night timeline (bar per night, 7 days, 19h–11h window)
+✅ Bottle feed from milk stock — log BOTTLE → select maternal lot → auto-mark as used
🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.)
🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers)
-🔲 Sleep log chart — visual night timeline (bar per night, wake markers)
-🔲 Bottle feed from milk stock — direct link: log BOTTLE → decrement milk stock
🔲 Data retention policy — auto-archive events older than N months
🔲 Offline support / PWA install prompt
🔲 Multi-language UI (FR/EN toggle)
diff --git a/src/app/(app)/stats/page.tsx b/src/app/(app)/stats/page.tsx
index ada6c04..00463f7 100644
--- a/src/app/(app)/stats/page.tsx
+++ b/src/app/(app)/stats/page.tsx
@@ -7,7 +7,7 @@ import {
LineChart, Line, CartesianGrid,
} from "recharts";
import { useBaby } from "@/contexts/baby-context";
-import { format, subDays, startOfDay, endOfDay } from "date-fns";
+import { format, subDays, startOfDay, endOfDay, isToday } from "date-fns";
import { fr } from "date-fns/locale";
import { EventType } from "@/lib/event-config";
@@ -110,6 +110,80 @@ function useDarkMode() {
return isDark;
}
+function SleepTimeline({ events }: { events: Event[] }) {
+ const WIN_START_H = 19;
+ const WIN_HOURS = 16; // 19:00 → 11:00 next day
+ const WIN_MS = WIN_HOURS * 3600000;
+
+ const rows = Array.from({ length: 7 }, (_, i) => {
+ const day = startOfDay(subDays(new Date(), 6 - i));
+ const winStart = day.getTime() + WIN_START_H * 3600000;
+ const winEnd = winStart + WIN_MS;
+
+ const blocks = events
+ .filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
+ .map((e) => ({ s: new Date(e.startedAt).getTime(), en: new Date(e.endedAt!).getTime(), type: e.type }))
+ .filter((e) => e.s < winEnd && e.en > winStart)
+ .map((e) => ({
+ left: (Math.max(e.s, winStart) - winStart) / WIN_MS * 100,
+ width: (Math.min(e.en, winEnd) - Math.max(e.s, winStart)) / WIN_MS * 100,
+ isNight: e.type === "NIGHT_SLEEP",
+ }));
+
+ return {
+ label: isToday(day) ? "Auj." : format(day, "EEE d", { locale: fr }),
+ isToday: isToday(day),
+ blocks,
+ };
+ });
+
+ const markers = [20, 22, 0, 2, 4, 6, 8, 10].map((h) => ({
+ label: h === 0 ? "00h" : `${h}h`,
+ pos: ((h < WIN_START_H ? h + 24 : h) - WIN_START_H) / WIN_HOURS * 100,
+ }));
+
+ return (
+
+
Nuits — 7 derniers jours
+
+ {markers.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+ {rows.map((row, i) => (
+
+
+ {row.label}
+
+
+ {row.blocks.map((b, j) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ );
+}
+
const DAY_OPTIONS = [7, 14, 30, 90] as const;
type DaysOption = typeof DAY_OPTIONS[number];
@@ -235,6 +309,8 @@ export default function StatsPage() {
+
+
{/* Feeding summary */}
Résumé des repas
diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts
index e49d05a..2cbdafe 100644
--- a/src/app/api/admin/config/route.ts
+++ b/src/app/api/admin/config/route.ts
@@ -17,6 +17,8 @@ const ALLOWED_KEYS = [
"pushover_app_token",
"allow_registration",
"maintenance_mode",
+ "cron_secret",
+ "growth_alert_days",
];
async function requireSuperAdmin() {
diff --git a/src/app/api/cron/growth-alert/route.ts b/src/app/api/cron/growth-alert/route.ts
new file mode 100644
index 0000000..64fad6e
--- /dev/null
+++ b/src/app/api/cron/growth-alert/route.ts
@@ -0,0 +1,79 @@
+import { NextResponse } from "next/server";
+import { prisma } from "@/lib/prisma";
+import { sendPushover } from "@/lib/push-notify";
+
+// Call daily with:
+// POST /api/cron/growth-alert
+// Authorization: Bearer
+
+async function getCronSecret(): Promise {
+ const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
+ return row?.value || process.env.CRON_SECRET || "";
+}
+
+async function getAlertDays(): Promise {
+ const row = await prisma.systemConfig.findUnique({ where: { key: "growth_alert_days" } });
+ const n = parseInt(row?.value ?? "");
+ return isNaN(n) || n <= 0 ? 30 : n;
+}
+
+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 alertDays = await getAlertDays();
+ const threshold = new Date(Date.now() - alertDays * 24 * 3600 * 1000);
+
+ const families = await prisma.family.findMany({
+ include: {
+ babies: {
+ include: {
+ growthLogs: {
+ orderBy: { date: "desc" },
+ take: 1,
+ },
+ },
+ },
+ users: {
+ where: { pushoverUserKey: { not: null } },
+ select: { pushoverUserKey: true },
+ },
+ },
+ });
+
+ const alerts: { familyId: string; babyName: string }[] = [];
+
+ for (const family of families) {
+ if (family.users.length === 0) continue;
+
+ for (const baby of family.babies) {
+ const lastLog = baby.growthLogs[0];
+ const lastDate = lastLog ? new Date(lastLog.date) : null;
+
+ if (!lastDate || lastDate < threshold) {
+ alerts.push({ familyId: family.id, babyName: baby.name });
+
+ const daysSince = lastDate
+ ? Math.floor((Date.now() - lastDate.getTime()) / 86400000)
+ : null;
+
+ const message = daysSince !== null
+ ? `Pas de mesure de croissance depuis ${daysSince} jours pour ${baby.name}. Pensez à peser votre bébé !`
+ : `Aucune mesure de croissance enregistrée pour ${baby.name}. Pensez à peser votre bébé !`;
+
+ for (const user of family.users) {
+ if (user.pushoverUserKey) {
+ await sendPushover(user.pushoverUserKey, message, `📏 Rappel croissance — ${baby.name}`);
+ }
+ }
+ }
+ }
+ }
+
+ return NextResponse.json({ ok: true, alertsSent: alerts.length, alerts, alertDays });
+}
diff --git a/src/components/event-modal.tsx b/src/components/event-modal.tsx
index 359ece7..741f81a 100644
--- a/src/components/event-modal.tsx
+++ b/src/components/event-modal.tsx
@@ -58,6 +58,15 @@ interface LastIntake {
unit?: string;
}
+interface MilkStock {
+ id: string;
+ volume: number;
+ location: string;
+ storedAt: string;
+ expiresAt: string;
+ notes: string | null;
+}
+
const STOOL_COLORS = [
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
@@ -124,6 +133,10 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
const [selectedProfileId, setSelectedProfileId] = useState(null);
const [crisisMode, setCrisisMode] = useState(false);
+ // Milk stock state (BOTTLE type)
+ const [milkStocks, setMilkStocks] = useState([]);
+ const [selectedStockId, setSelectedStockId] = useState(null);
+
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null;
useEffect(() => {
@@ -137,6 +150,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
});
}, [type, babyId]);
+ useEffect(() => {
+ if (type !== "BOTTLE") return;
+ fetch(`/api/milk?babyId=${babyId}`)
+ .then((r) => r.json())
+ .then((stocks) => {
+ if (Array.isArray(stocks)) {
+ const available = stocks.filter((s: MilkStock & { used?: boolean }) => !s.used);
+ available.sort((a: MilkStock, b: MilkStock) => new Date(a.storedAt).getTime() - new Date(b.storedAt).getTime());
+ setMilkStocks(available);
+ if (available.length > 0) setSelectedStockId(available[0].id);
+ }
+ });
+ }, [type, babyId]);
+
// Auto-fill dose/unit when profile selected
useEffect(() => {
if (!selectedProfile) return;
@@ -293,6 +320,13 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
}),
});
}
+ if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
+ await fetch(`/api/milk/${selectedStockId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ used: true }),
+ });
+ }
setLoading(false);
onSaved();
onClose();
@@ -376,6 +410,40 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
))}
+ {form.bottleType === "maternal" && milkStocks.length > 0 && (
+
+
+
+
+ {milkStocks.map((s) => (
+
+ ))}
+
+ {selectedStockId && (
+
Ce lot sera marqué comme utilisé après enregistrement.
+ )}
+
+ )}
)}