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 />
+64 -8
View File
@@ -11,7 +11,7 @@ import {
import { useBaby } from "@/contexts/baby-context";
import { format, subDays } from "date-fns";
import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Plus, Pencil, Trash2, FileDown } from "lucide-react";
interface GrowthLog {
id: string;
@@ -241,6 +241,53 @@ export default function GrowthPage() {
qc.invalidateQueries({ queryKey: ["growth"] });
}
function exportPDF() {
import("jspdf").then(({ default: jsPDF }) => {
const doc = new jsPDF();
const pageW = doc.internal.pageSize.getWidth();
doc.setFontSize(18);
doc.setTextColor(30, 41, 59);
doc.text("Courbe de croissance", 14, 20);
doc.setFontSize(11);
doc.setTextColor(100, 116, 139);
doc.text(`${baby!.name} · né${baby!.gender === "F" ? "e" : ""} le ${format(new Date(baby!.birthDate), "d MMMM yyyy", { locale: fr })}`, 14, 28);
doc.text(`Généré le ${format(new Date(), "d MMMM yyyy", { locale: fr })}`, 14, 34);
// Table header
const COL = [14, 55, 95, 130, 155];
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
let y = 46;
doc.setFillColor(238, 242, 255);
doc.rect(14, y - 5, pageW - 28, 9, "F");
doc.setFontSize(9);
doc.setTextColor(79, 70, 229);
headers.forEach((h, i) => doc.text(h, COL[i], y));
y += 8;
doc.setFontSize(9);
const sorted = [...logs].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
sorted.forEach((log, idx) => {
if (y > 270) { doc.addPage(); y = 20; }
if (idx % 2 === 0) {
doc.setFillColor(248, 250, 252);
doc.rect(14, y - 4, pageW - 28, 7, "F");
}
doc.setTextColor(30, 41, 59);
doc.text(format(new Date(log.date), "d MMM yyyy", { locale: fr }), COL[0], y);
doc.text(log.weight ? (log.weight / 1000).toFixed(3) : "—", COL[1], y);
doc.text(log.height ? String(log.height) : "—", COL[2], y);
doc.text(log.headCirc ? String(log.headCirc) : "—", COL[3], y);
if (log.notes) doc.text(log.notes.slice(0, 30), COL[4], y);
y += 7;
});
doc.save(`croissance-${baby!.name.toLowerCase()}.pdf`);
});
}
const latest = logs.at(-1);
const rangeOptions: { label: string; value: TimeRange }[] = [
@@ -257,13 +304,22 @@ export default function GrowthPage() {
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Courbes de croissance</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
</div>
<button
onClick={() => showForm ? setShowForm(false) : openAddForm()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Mesure
</button>
<div className="flex gap-2">
{logs.length > 0 && (
<button onClick={exportPDF}
className="flex items-center gap-2 px-3 py-2 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 text-sm font-medium rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition">
<FileDown className="w-4 h-4" />
PDF
</button>
)}
<button
onClick={() => showForm ? setShowForm(false) : openAddForm()}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Mesure
</button>
</div>
</div>
{/* Add measurement form */}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { EVENT_CONFIG, EventType, formatDuration } from "@/lib/event-config";
import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { Search, FileText, StickyNote } from "lucide-react";
interface SearchEvent {
id: string;
type: EventType;
startedAt: string;
endedAt?: string;
notes?: string;
metadata?: Record<string, unknown>;
user: { id: string; name: string };
}
interface SearchNote {
id: string;
date: string;
content: string;
user: { id: string; name: string };
}
function eventSummary(ev: SearchEvent): string {
const meta = ev.metadata ?? {};
if (ev.type === "BREASTFEED") {
const side = meta.breastSide === "left" ? "gauche" : meta.breastSide === "right" ? "droite" : "les deux";
if (ev.endedAt) return `${formatDuration(new Date(ev.startedAt), new Date(ev.endedAt))} · ${side}`;
return side;
}
if (ev.type === "BOTTLE") return meta.volume ? `${meta.volume} mL` : "";
if (ev.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (ev.type === "MEDICATION") return String(meta.name ?? "");
if (ev.type === "SYMPTOM") return ((meta.tags as string[]) ?? []).join(", ");
if ((ev.type === "NAP" || ev.type === "NIGHT_SLEEP") && ev.endedAt)
return formatDuration(new Date(ev.startedAt), new Date(ev.endedAt));
return "";
}
function highlight(text: string, q: string): React.ReactNode {
if (!q || !text) return text;
const idx = text.toLowerCase().indexOf(q.toLowerCase());
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="bg-yellow-200 dark:bg-yellow-700 rounded px-0.5">{text.slice(idx, idx + q.length)}</mark>
{text.slice(idx + q.length)}
</>
);
}
export default function SearchPage() {
const { selectedBaby } = useBaby();
const [q, setQ] = useState("");
const [debouncedQ, setDebouncedQ] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { inputRef.current?.focus(); }, []);
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q), 300);
return () => clearTimeout(t);
}, [q]);
const { data, isFetching } = useQuery<{ events: SearchEvent[]; notes: SearchNote[] }>({
queryKey: ["search", selectedBaby?.id, debouncedQ],
queryFn: () =>
fetch(`/api/search?babyId=${selectedBaby!.id}&q=${encodeURIComponent(debouncedQ)}`).then((r) => r.json()),
enabled: !!selectedBaby?.id && debouncedQ.length >= 2,
placeholderData: { events: [], notes: [] },
});
const events = data?.events ?? [];
const notes = data?.notes ?? [];
const hasResults = events.length > 0 || notes.length > 0;
const searched = debouncedQ.length >= 2;
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-5">Recherche</h1>
<div className="relative mb-6">
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
ref={inputRef}
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Rechercher dans les notes et événements…"
className="w-full pl-10 pr-4 py-3 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{isFetching && (
<div className="absolute right-3.5 top-1/2 -translate-y-1/2 w-4 h-4 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin" />
)}
</div>
{!searched && (
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-12">Saisissez au moins 2 caractères</p>
)}
{searched && !hasResults && !isFetching && (
<p className="text-sm text-slate-400 dark:text-slate-500 text-center py-12">Aucun résultat pour « {debouncedQ} »</p>
)}
{events.length > 0 && (
<div className="mb-6">
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5" />
Événements · {events.length}
</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{events.map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
const summary = eventSummary(ev);
return (
<div key={ev.id} className="flex items-start gap-3 px-4 py-3">
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0 mt-0.5`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">{cfg.label}</span>
{summary && <span className="text-xs text-slate-400 dark:text-slate-500">{summary}</span>}
</div>
{ev.notes && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-2">
{highlight(ev.notes, debouncedQ)}
</p>
)}
<p className="text-[10px] text-slate-400 dark:text-slate-500 mt-1">
{format(new Date(ev.startedAt), "d MMM yyyy HH:mm", { locale: fr })} · {ev.user.name}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{notes.length > 0 && (
<div>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-1.5">
<StickyNote className="w-3.5 h-3.5" />
Journal · {notes.length}
</h2>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{notes.map((note) => (
<div key={note.id} className="px-4 py-3">
<p className="text-xs text-slate-400 dark:text-slate-500 mb-1">
{format(new Date(note.date), "d MMMM yyyy", { locale: fr })} · {note.user.name}
</p>
<p className="text-sm text-slate-700 dark:text-slate-200 line-clamp-3">
{highlight(note.content, debouncedQ)}
</p>
</div>
))}
</div>
</div>
)}
</div>
);
}
+4
View File
@@ -57,6 +57,10 @@ 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.length > 0 ? tags.join(", ") : "";
}
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}