diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index d69e6fa..796c6ac 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -60,7 +60,8 @@ function getEventSummary(event: Event): string { if (event.type === "BOTTLE") { 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 === "MEDICATION") return String(meta.name ?? ""); if (event.type === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); } diff --git a/src/app/(app)/stats/page.tsx b/src/app/(app)/stats/page.tsx index d50016a..f89e052 100644 --- a/src/app/(app)/stats/page.tsx +++ b/src/app/(app)/stats/page.tsx @@ -16,6 +16,17 @@ interface Event { type: EventType; startedAt: string; endedAt?: string; + metadata?: Record; +} + +interface MilkStock { + id: string; + volume: number; + storedAt: string; + expiresAt: string; + location: string; + used: boolean; + usedAt?: string | null; } 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() { const [isDark, setIsDark] = useState(false); useEffect(() => { @@ -366,6 +390,12 @@ export default function StatsPage() { enabled: !!baby?.id, }); + const { data: milkStocks } = useQuery({ + 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 hitLimit = events.length >= 500; const weeklyData = buildWeeklyData(events, days); @@ -375,6 +405,20 @@ export default function StatsPage() { const feedingStats = computeFeedingStats(events); 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>((acc, s) => { + acc[s.location] = (acc[s.location] ?? 0) + s.volume; + return acc; + }, {}); + const LOCATION_LABELS: Record = { room: "Ambiant", fridge: "Frigo", freezer: "Congélateur" }; + const LOCATION_COLORS: Record = { 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 todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length; @@ -508,6 +552,56 @@ export default function StatsPage() { + {/* Milk stock stats */} + {baby?.hasPump && ( +
+
+

Stock de lait

+

Lots disponibles · {periodSubtitle(days)}

+
+
+

En stock

+

{Math.round(totalStockMl)} mL

+
+
+

Lots dispo

+

{availableStocks.length}

+
+
+

Pompé (période)

+

{totalPumpedMl} mL

+
+
+ {expiringSoon > 0 && ( +
+ {expiringSoon} lot{expiringSoon > 1 ? "s" : ""} expire{expiringSoon > 1 ? "nt" : ""} dans moins de 24h +
+ )} + {Object.keys(stockByLocation).length > 0 && ( +
+ {Object.entries(stockByLocation).map(([loc, vol]) => ( + + {LOCATION_LABELS[loc] ?? loc} · {Math.round(vol)} mL + + ))} +
+ )} +
+
+

Volume pompé par jour

+

mL · {periodSubtitle(days)}

+ + + + + [`${val} mL`, "Pompé"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} /> + + + +
+
+ )} +
{/* Feeds */}
diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx index fe068fe..74eedb5 100644 --- a/src/app/(app)/timeline/page.tsx +++ b/src/app/(app)/timeline/page.tsx @@ -55,7 +55,8 @@ function getEventSummary(event: Event): string { if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt))); 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 === "MEDICATION") return String(meta.name ?? ""); if (event.type === "SYMPTOM") { diff --git a/src/components/event-modal.tsx b/src/components/event-modal.tsx index 7ca88b2..af64a93 100644 --- a/src/components/event-modal.tsx +++ b/src/components/event-modal.tsx @@ -33,6 +33,7 @@ interface FormData { pumpVolume?: string; stoolColor?: string; stoolAmount?: "small" | "medium" | "large"; + leaked?: boolean; medName?: string; medDose?: 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, stoolColor: (meta.color as string) ?? "jaune", stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium", + leaked: (meta.leaked as boolean) ?? false, medName: (meta.name as string) ?? undefined, medDose: (meta.dose 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 === "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_WET" || type === "DIAPER_STOOL") { if (form.leaked) metadata.leaked = true; } if (type === "MEDICATION") { metadata.name = form.medName; 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")) { const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET"; const secondMeta = type === "DIAPER_WET" - ? { color: form.stoolColor, amount: form.stoolAmount } - : null; + ? { color: form.stoolColor, amount: form.stoolAmount, ...(form.leaked ? { leaked: true } : {}) } + : form.leaked ? { leaked: true } : null; await fetch("/api/events", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -666,6 +669,20 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
)} + {/* Leaked diaper toggle */} + {(type === "DIAPER_WET" || type === "DIAPER_STOOL") && ( + + )} + {/* Combined diaper toggle */} {(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (