feat: running timers, symptom log, search, PDF export

- dashboard: ActiveTimers widget — live h:mm:ss for any in-progress timed event (breastfeed, nap, sleep, walk…), Terminer button patches endedAt
- dashboard: reorganised quick-log groups; Santé group (Température, Traitement, Symptôme)
- events: SYMPTOM type — tag chips (Fièvre, Reflux, Colique, Éruption, Toux, Congestion, Diarrhée, Vomissement, Irritabilité, Pleurs excessifs), stored in metadata.tags
- prisma: migration adding SYMPTOM to EventType enum
- search: GET /api/search (events by notes ILIKE, journal by content ILIKE), /search page with debounced input + keyword highlight
- nav: Recherche link added to LINKS + More drawer
- growth: PDF export button — generates formatted table with all measurements, zebra rows, downloads croissance-{name}.pdf

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:27:01 +02:00
parent cd677d9244
commit 009090b381
14 changed files with 549 additions and 10 deletions
+69 -1
View File
@@ -15,12 +15,16 @@ import { useSession } from "next-auth/react";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
{
label: "Alimentation & Soins",
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL", "MEDICATION", "TEMPERATURE"],
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"],
},
{
label: "Sommeil & Activités",
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
},
{
label: "Santé",
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
},
];
interface Event {
@@ -64,12 +68,73 @@ function getEventSummary(event: Event): string {
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
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(", "); }
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
return "";
}
function formatElapsed(startedAt: string, now: Date): string {
const ms = Math.max(0, now.getTime() - new Date(startedAt).getTime());
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
if (h > 0) return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
return `${m}:${s.toString().padStart(2, "0")}`;
}
function ActiveTimers({ events, onStop }: { events: Event[]; onStop: () => void }) {
const [now, setNow] = useState(() => new Date());
const active = events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer);
useEffect(() => {
if (active.length === 0) return;
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, [active.length]);
if (active.length === 0) return null;
return (
<div className="mb-6 space-y-2">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">En cours</h2>
{active.map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
return (
<div key={ev.id} className={`flex items-center gap-3 px-4 py-3 rounded-xl border ${cfg.bgClass} ${cfg.borderClass}`}>
<div className={`w-9 h-9 rounded-lg bg-white/60 dark:bg-slate-900/40 flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-5 h-5 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">
Depuis {format(new Date(ev.startedAt), "HH:mm")}
</p>
</div>
<span className={`text-lg font-bold font-mono ${cfg.colorClass} tabular-nums`}>
{formatElapsed(ev.startedAt, now)}
</span>
<button
onClick={async () => {
await fetch(`/api/events/${ev.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endedAt: new Date().toISOString() }),
});
onStop();
}}
className="ml-1 px-3 py-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-xs font-medium text-slate-600 dark:text-slate-300 hover:border-red-300 hover:text-red-600 transition"
>
Terminer
</button>
</div>
);
})}
</div>
);
}
function QuickNoteWidget({ babyId }: { babyId: string }) {
const qc = useQueryClient();
const todayStr = format(new Date(), "yyyy-MM-dd");
@@ -512,6 +577,9 @@ export default function DashboardPage() {
{/* Handoff summary */}
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
{/* Active timers */}
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} />
{/* Pinned note + Quick note */}
<div className="grid sm:grid-cols-2 gap-4 mb-6">
<PinnedNoteWidget />