feat: sleep chart, bottle-from-stock, growth alert cron
- 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 <noreply@anthropic.com>
This commit is contained in:
+3
-5
@@ -54,18 +54,16 @@
|
|||||||
|
|
||||||
## Feature ideas / backlog
|
## 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
|
✅ 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)
|
✅ Baby activities log — BATH, WALK, TUMMY_TIME event types (timer + duration)
|
||||||
✅ Pinned family note — shared editable note on dashboard; /api/family/pinned-note
|
✅ 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
|
✅ 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
|
✅ 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.)
|
🔲 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)
|
||||||
🔲 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
|
🔲 Data retention policy — auto-archive events older than N months
|
||||||
🔲 Offline support / PWA install prompt
|
🔲 Offline support / PWA install prompt
|
||||||
🔲 Multi-language UI (FR/EN toggle)
|
🔲 Multi-language UI (FR/EN toggle)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
LineChart, Line, CartesianGrid,
|
LineChart, Line, CartesianGrid,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useBaby } from "@/contexts/baby-context";
|
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 { fr } from "date-fns/locale";
|
||||||
import { EventType } from "@/lib/event-config";
|
import { EventType } from "@/lib/event-config";
|
||||||
|
|
||||||
@@ -110,6 +110,80 @@ function useDarkMode() {
|
|||||||
return isDark;
|
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 (
|
||||||
|
<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-4">Nuits — 7 derniers jours</p>
|
||||||
|
<div className="relative h-4 ml-10 mb-0.5">
|
||||||
|
{markers.map((m) => (
|
||||||
|
<span key={m.label} className="absolute text-[9px] text-slate-400 dark:text-slate-500 -translate-x-1/2" style={{ left: `${m.pos}%` }}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 mt-1">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] w-10 flex-shrink-0 text-right font-medium ${row.isToday ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400 dark:text-slate-500"}`}>
|
||||||
|
{row.label}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex-1 h-4 bg-slate-100 dark:bg-slate-700/50 rounded-full overflow-hidden">
|
||||||
|
{row.blocks.map((b, j) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className={`absolute top-0 h-full ${b.isNight ? "bg-indigo-500 dark:bg-indigo-400" : "bg-violet-400 dark:bg-violet-500"}`}
|
||||||
|
style={{ left: `${b.left}%`, width: `${Math.max(b.width, 0.8)}%` }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-2 rounded-sm bg-indigo-500" />
|
||||||
|
<span className="text-[10px] text-slate-400 dark:text-slate-500">Nuit</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-2 rounded-sm bg-violet-400" />
|
||||||
|
<span className="text-[10px] text-slate-400 dark:text-slate-500">Sieste</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const DAY_OPTIONS = [7, 14, 30, 90] as const;
|
const DAY_OPTIONS = [7, 14, 30, 90] as const;
|
||||||
type DaysOption = typeof DAY_OPTIONS[number];
|
type DaysOption = typeof DAY_OPTIONS[number];
|
||||||
|
|
||||||
@@ -235,6 +309,8 @@ export default function StatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SleepTimeline events={events} />
|
||||||
|
|
||||||
{/* 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>
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const ALLOWED_KEYS = [
|
|||||||
"pushover_app_token",
|
"pushover_app_token",
|
||||||
"allow_registration",
|
"allow_registration",
|
||||||
"maintenance_mode",
|
"maintenance_mode",
|
||||||
|
"cron_secret",
|
||||||
|
"growth_alert_days",
|
||||||
];
|
];
|
||||||
|
|
||||||
async function requireSuperAdmin() {
|
async function requireSuperAdmin() {
|
||||||
|
|||||||
@@ -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 <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 getAlertDays(): Promise<number> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -58,6 +58,15 @@ interface LastIntake {
|
|||||||
unit?: string;
|
unit?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MilkStock {
|
||||||
|
id: string;
|
||||||
|
volume: number;
|
||||||
|
location: string;
|
||||||
|
storedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const STOOL_COLORS = [
|
const STOOL_COLORS = [
|
||||||
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
|
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
|
||||||
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
|
{ 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<string | null>(null);
|
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
|
||||||
const [crisisMode, setCrisisMode] = useState(false);
|
const [crisisMode, setCrisisMode] = useState(false);
|
||||||
|
|
||||||
|
// Milk stock state (BOTTLE type)
|
||||||
|
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
|
||||||
|
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
||||||
|
|
||||||
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
const selectedProfile = profiles.find((p) => p.id === selectedProfileId) ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,6 +150,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
});
|
});
|
||||||
}, [type, babyId]);
|
}, [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
|
// Auto-fill dose/unit when profile selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProfile) return;
|
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);
|
setLoading(false);
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
@@ -376,6 +410,40 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{form.bottleType === "maternal" && milkStocks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||||
|
Lot de stock ({milkStocks.length} disponible{milkStocks.length > 1 ? "s" : ""})
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedStockId(null)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg border text-xs font-medium transition ${
|
||||||
|
selectedStockId === null ? "border-indigo-500 bg-indigo-50 text-indigo-700" : "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Aucun
|
||||||
|
</button>
|
||||||
|
{milkStocks.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedStockId(s.id)}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg border text-xs font-medium transition ${
|
||||||
|
selectedStockId === s.id ? "border-indigo-500 bg-indigo-50 text-indigo-700" : "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.volume} mL · {s.location === "fridge" ? "frigo" : s.location === "freezer" ? "congél." : "ambiant"}
|
||||||
|
{" · "}{format(new Date(s.storedAt), "d MMM", { locale: fr })}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedStockId && (
|
||||||
|
<p className="text-[11px] text-indigo-500 mt-1.5">Ce lot sera marqué comme utilisé après enregistrement.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user