From 81d7e5ad737e95b898d323070d495d0cad9a8830 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Sat, 13 Jun 2026 15:58:13 +0200 Subject: [PATCH] feat: sleep chart, bottle-from-stock, growth alert cron MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SleepTimeline component in stats: 7-night bar chart, 19h–11h window, NIGHT_SLEEP (indigo) and NAP (violet) blocks, pure CSS positioning - Bottle modal: fetch available maternal milk lots, chip picker, auto-selects oldest, marks selected lot as used after save - /api/cron/growth-alert: Bearer-auth POST, configurable threshold via growth_alert_days SystemConfig key (default 30 days), Pushover alert to all family users per baby with no recent weight log - Admin config ALLOWED_KEYS: add cron_secret + growth_alert_days Co-Authored-By: Claude Sonnet 4.6 --- FEATURES.md | 8 +-- src/app/(app)/stats/page.tsx | 78 ++++++++++++++++++++++++- src/app/api/admin/config/route.ts | 2 + src/app/api/cron/growth-alert/route.ts | 79 ++++++++++++++++++++++++++ src/components/event-modal.tsx | 68 ++++++++++++++++++++++ 5 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 src/app/api/cron/growth-alert/route.ts 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) => ( +
+ ))} +
+
+ ))} +
+
+
+
+ Nuit +
+
+
+ Sieste +
+
+
+ ); +} + 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.

+ )} +
+ )} )}