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:
2026-06-13 15:58:13 +02:00
parent 24daa5eb15
commit 81d7e5ad73
5 changed files with 229 additions and 6 deletions
+77 -1
View File
@@ -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 (
<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;
type DaysOption = typeof DAY_OPTIONS[number];
@@ -235,6 +309,8 @@ export default function StatsPage() {
</div>
</div>
<SleepTimeline events={events} />
{/* Feeding summary */}
<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>