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:
@@ -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(", "); }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user