fix: UX audit — code-review findings

- Photo client-side limit: 5MB → 30MB (matches server)
- OTHER event shows notes preview in dashboard + timeline summaries
- Stats: add "Activités par jour" chart (Bain/Balade/Plat ventre/Autre)
- Combined diaper (wet→stool): show color/amount pickers, pass metadata
- Quick-add FAB: 4-col → 5-col grid (14 types in 3 rows instead of 4)
- Stool color buttons: force dark text, readable on light bg in dark mode
- Reminders: mark taken (✓) sets lastSentAt, ↺ clears it; shows elapsed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:06:54 +02:00
parent da60efd7c1
commit 6dc09fb696
7 changed files with 111 additions and 16 deletions
+1
View File
@@ -70,6 +70,7 @@ function getEventSummary(event: Event): string {
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
return "";
}
+44 -1
View File
@@ -3,7 +3,9 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { Plus, Trash2, Bell, BellOff, Clock } from "lucide-react";
import { Plus, Trash2, Bell, BellOff, Clock, CheckCircle2, RotateCcw } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
interface Reminder {
id: string;
@@ -72,6 +74,24 @@ export default function RemindersPage() {
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function markTaken(id: string) {
await fetch(`/api/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastSentAt: new Date().toISOString() }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function unmarkTaken(id: string) {
await fetch(`/api/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastSentAt: null }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function deleteReminder(id: string) {
if (!confirm("Supprimer ce rappel ?")) return;
await fetch(`/api/reminders/${id}`, { method: "DELETE" });
@@ -175,8 +195,31 @@ export default function RemindersPage() {
<span className="text-xs text-indigo-600 dark:text-indigo-400 ml-2">{nextDueLabel(r)}</span>
)}
</div>
{r.lastSentAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Pris {formatDistanceToNow(new Date(r.lastSentAt), { locale: fr, addSuffix: true })}
</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{r.lastSentAt ? (
<button
onClick={() => unmarkTaken(r.id)}
title="Annuler la prise"
className="w-8 h-8 flex items-center justify-center rounded-lg text-green-500 hover:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
) : (
<button
onClick={() => markTaken(r.id)}
title="Marquer comme pris"
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
>
<CheckCircle2 className="w-4 h-4" />
</button>
)}
<button
onClick={() => toggleEnabled(r.id, r.enabled)}
className={`w-9 h-5 rounded-full transition relative overflow-hidden ${r.enabled ? "bg-green-500" : "bg-slate-200 dark:bg-slate-700"}`}
+16 -1
View File
@@ -30,10 +30,11 @@ function buildWeeklyData(events: Event[], days: number) {
});
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
const activities = dayEvents.filter((e) => e.type === "BATH" || e.type === "WALK" || e.type === "TUMMY_TIME" || e.type === "OTHER").length;
const sleepMins = dayEvents
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
.reduce((acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, 0);
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, activities, sleep: Math.round(sleepMins / 60 * 10) / 10 };
});
}
@@ -534,6 +535,20 @@ export default function StatsPage() {
</ResponsiveContainer>
</div>
{/* Activities */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Activités par jour</h3>
<p className="text-[10px] text-slate-400 dark:text-slate-500 mb-4">Bain · Balade · Plat ventre · Autre</p>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} 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} allowDecimals={false} />
<Tooltip formatter={(val) => [val, "Activités"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="activities" fill="#65a30d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Sleep */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
+1
View File
@@ -65,6 +65,7 @@ function getEventSummary(event: Event): string {
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
return "";
}
+1
View File
@@ -26,6 +26,7 @@ export async function PATCH(
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.lastSentAt !== undefined ? { lastSentAt: body.lastSentAt ? new Date(body.lastSentAt) : null } : {}),
},
});
+47 -13
View File
@@ -249,8 +249,8 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert("Image trop volumineuse (max 5 Mo)");
if (file.size > 30 * 1024 * 1024) {
alert("Image trop volumineuse (max 30 Mo)");
return;
}
setPhotoUploading(true);
@@ -363,6 +363,9 @@ 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;
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -372,7 +375,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
startedAt: toUTC(form.startedAt),
endedAt: null,
notes: null,
metadata: null,
metadata: secondMeta,
}),
});
}
@@ -657,16 +660,47 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
{/* Combined diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (
<button
type="button"
onClick={() => setDiaperBoth((v) => !v)}
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>{type === "DIAPER_WET" ? "Aussi des selles" : "Aussi mouillée"}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${diaperBoth ? "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 ${diaperBoth ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
<div className="space-y-3">
<button
type="button"
onClick={() => setDiaperBoth((v) => !v)}
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>{type === "DIAPER_WET" ? "Aussi des selles" : "Aussi mouillée"}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${diaperBoth ? "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 ${diaperBoth ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
{diaperBoth && type === "DIAPER_WET" && (
<div className="space-y-3 pl-1">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur des selles</label>
<div className="grid grid-cols-3 gap-2">
{STOOL_COLORS.map(({ value, label, bg, dot }) => (
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium text-slate-800 transition ${
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
}`} style={{ background: bg }}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
{label}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Quantité</label>
<div className="flex gap-2">
{(["small", "medium", "large"] as const).map((amt) => (
<button key={amt} type="button" onClick={() => setForm((f) => ({ ...f, stoolAmount: amt }))}
className={`${segmentBase} ${form.stoolAmount === amt ? segmentActive : segmentInactive}`}>
{amt === "small" ? "Peu" : amt === "medium" ? "Moyen" : "Beaucoup"}
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* Medication — profile picker */}
+1 -1
View File
@@ -74,7 +74,7 @@ export function QuickAddFab() {
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 px-4 pb-2">
<div className="grid grid-cols-5 gap-2 px-4 pb-2">
{QUICK_TYPES.map((type) => {
const cfg = EVENT_CONFIG[type];
return (