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