feat(diaper,milk): leaking diapers toggle + milk stock stats

- Add 'Débordement' toggle to diaper events (wet + stool), stored as
  metadata.leaked; shown in timeline and dashboard summaries
- Add milk stock stats section to stats page (gated on hasPump):
  summary cards (stock mL, lots, pumped in period), 24h expiry warning,
  location breakdown pills, and daily pump volume bar chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 01:07:06 +02:00
parent 4abee45877
commit febf9ed107
4 changed files with 117 additions and 4 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ function getEventSummary(event: Event): string {
if (event.type === "BOTTLE") { if (event.type === "BOTTLE") {
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · "); return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
} }
if (event.type === "DIAPER_STOOL") return String(meta.color ?? ""); if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : ""; if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? ""); if (event.type === "MEDICATION") return String(meta.name ?? "");
if (event.type === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); } if (event.type === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); }
+94
View File
@@ -16,6 +16,17 @@ interface Event {
type: EventType; type: EventType;
startedAt: string; startedAt: string;
endedAt?: string; endedAt?: string;
metadata?: Record<string, unknown>;
}
interface MilkStock {
id: string;
volume: number;
storedAt: string;
expiresAt: string;
location: string;
used: boolean;
usedAt?: string | null;
} }
function buildWeeklyData(events: Event[], days: number) { function buildWeeklyData(events: Event[], days: number) {
@@ -126,6 +137,19 @@ function buildDurationTrend(events: Event[], days: number) {
}); });
} }
function buildMilkDailyData(events: Event[], days: number) {
const labelFormat = days <= 7 ? "EEE" : "d/M";
return Array.from({ length: days }, (_, i) => {
const d = subDays(new Date(), days - 1 - i);
const key = format(d, "yyyy-MM-dd");
const label = format(d, labelFormat, { locale: fr });
const pumped = events
.filter((e) => e.type === "PUMP" && format(new Date(e.startedAt), "yyyy-MM-dd") === key)
.reduce((acc, e) => acc + (typeof e.metadata?.volume === "number" ? e.metadata.volume : 0), 0);
return { label, pumped: Math.round(pumped) };
});
}
function useDarkMode() { function useDarkMode() {
const [isDark, setIsDark] = useState(false); const [isDark, setIsDark] = useState(false);
useEffect(() => { useEffect(() => {
@@ -366,6 +390,12 @@ export default function StatsPage() {
enabled: !!baby?.id, enabled: !!baby?.id,
}); });
const { data: milkStocks } = useQuery<MilkStock[]>({
queryKey: ["milk", baby?.id],
queryFn: () => fetch(`/api/milk?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id && !!baby?.hasPump,
});
const events: Event[] = eventsData?.events ?? []; const events: Event[] = eventsData?.events ?? [];
const hitLimit = events.length >= 500; const hitLimit = events.length >= 500;
const weeklyData = buildWeeklyData(events, days); const weeklyData = buildWeeklyData(events, days);
@@ -375,6 +405,20 @@ export default function StatsPage() {
const feedingStats = computeFeedingStats(events); const feedingStats = computeFeedingStats(events);
const diaperStreak = computeDiaperStreak(events, days); const diaperStreak = computeDiaperStreak(events, days);
const milkDailyData = buildMilkDailyData(events, days);
const totalPumpedMl = milkDailyData.reduce((acc, d) => acc + d.pumped, 0);
const availableStocks = (milkStocks ?? []).filter((s) => !s.used);
const totalStockMl = availableStocks.reduce((acc, s) => acc + s.volume, 0);
const stockByLocation = availableStocks.reduce<Record<string, number>>((acc, s) => {
acc[s.location] = (acc[s.location] ?? 0) + s.volume;
return acc;
}, {});
const LOCATION_LABELS: Record<string, string> = { room: "Ambiant", fridge: "Frigo", freezer: "Congélateur" };
const LOCATION_COLORS: Record<string, string> = { room: "text-amber-600", fridge: "text-cyan-600", freezer: "text-indigo-600" };
const expiringSoon = availableStocks.filter((s) => {
const diff = new Date(s.expiresAt).getTime() - Date.now();
return diff > 0 && diff < 24 * 3600000;
}).length;
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;
@@ -508,6 +552,56 @@ export default function StatsPage() {
</div> </div>
</div> </div>
{/* Milk stock stats */}
{baby?.hasPump && (
<div className="mb-4 space-y-4">
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">Stock de lait</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Lots disponibles · {periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-2 md:gap-4 mb-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">En stock</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{Math.round(totalStockMl)} <span className="text-sm font-normal text-slate-400">mL</span></p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Lots dispo</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{availableStocks.length}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Pompé (période)</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{totalPumpedMl} <span className="text-sm font-normal text-slate-400">mL</span></p>
</div>
</div>
{expiringSoon > 0 && (
<div className="mb-4 px-3 py-2 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg text-xs text-amber-700 dark:text-amber-300">
{expiringSoon} lot{expiringSoon > 1 ? "s" : ""} expire{expiringSoon > 1 ? "nt" : ""} dans moins de 24h
</div>
)}
{Object.keys(stockByLocation).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(stockByLocation).map(([loc, vol]) => (
<span key={loc} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 ${LOCATION_COLORS[loc] ?? "text-slate-600"}`}>
{LOCATION_LABELS[loc] ?? loc} · {Math.round(vol)} mL
</span>
))}
</div>
)}
</div>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Volume pompé par jour</h3>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">mL · {periodSubtitle(days)}</p>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={milkDailyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip formatter={(val) => [`${val} mL`, "Pompé"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="pumped" fill="#0891b2" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="grid md:grid-cols-2 gap-4"> <div className="grid md:grid-cols-2 gap-4">
{/* Feeds */} {/* Feeds */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4"> <div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
+2 -1
View File
@@ -55,7 +55,8 @@ function getEventSummary(event: Event): string {
if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt))); if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt)));
return parts.filter(Boolean).join(" · "); return parts.filter(Boolean).join(" · ");
} }
if (event.type === "DIAPER_STOOL") return String(meta.color ?? ""); if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : ""; if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? ""); if (event.type === "MEDICATION") return String(meta.name ?? "");
if (event.type === "SYMPTOM") { if (event.type === "SYMPTOM") {
+19 -2
View File
@@ -33,6 +33,7 @@ interface FormData {
pumpVolume?: string; pumpVolume?: string;
stoolColor?: string; stoolColor?: string;
stoolAmount?: "small" | "medium" | "large"; stoolAmount?: "small" | "medium" | "large";
leaked?: boolean;
medName?: string; medName?: string;
medDose?: string; medDose?: string;
medUnit?: string; medUnit?: string;
@@ -112,6 +113,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined, volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined,
stoolColor: (meta.color as string) ?? "jaune", stoolColor: (meta.color as string) ?? "jaune",
stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium", stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium",
leaked: (meta.leaked as boolean) ?? false,
medName: (meta.name as string) ?? undefined, medName: (meta.name as string) ?? undefined,
medDose: (meta.dose as string) ?? undefined, medDose: (meta.dose as string) ?? undefined,
medUnit: (meta.unit as string) ?? undefined, medUnit: (meta.unit as string) ?? undefined,
@@ -317,6 +319,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; } if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; } if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; }
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; } if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
if (type === "DIAPER_WET" || type === "DIAPER_STOOL") { if (form.leaked) metadata.leaked = true; }
if (type === "MEDICATION") { if (type === "MEDICATION") {
metadata.name = form.medName; metadata.name = form.medName;
metadata.dose = form.medDose; metadata.dose = form.medDose;
@@ -372,8 +375,8 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
if (diaperBoth && (type === "DIAPER_WET" || type === "DIAPER_STOOL")) { if (diaperBoth && (type === "DIAPER_WET" || type === "DIAPER_STOOL")) {
const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET"; const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET";
const secondMeta = type === "DIAPER_WET" const secondMeta = type === "DIAPER_WET"
? { color: form.stoolColor, amount: form.stoolAmount } ? { color: form.stoolColor, amount: form.stoolAmount, ...(form.leaked ? { leaked: true } : {}) }
: null; : form.leaked ? { leaked: true } : null;
await fetch("/api/events", { await fetch("/api/events", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -666,6 +669,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
</div> </div>
)} )}
{/* Leaked diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && (
<button
type="button"
onClick={() => setForm((f) => ({ ...f, leaked: !f.leaked }))}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>Débordement</span>
<div className={`w-9 h-5 rounded-full transition-colors ${form.leaked ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${form.leaked ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
)}
{/* Combined diaper toggle */} {/* Combined diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && ( {(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (
<div className="space-y-3"> <div className="space-y-3">