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
@@ -0,0 +1,156 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "EventType" ADD VALUE 'PUMP';
ALTER TYPE "EventType" ADD VALUE 'BATH';
ALTER TYPE "EventType" ADD VALUE 'WALK';
ALTER TYPE "EventType" ADD VALUE 'TUMMY_TIME';
-- AlterTable
ALTER TABLE "Family" ADD COLUMN "pinnedNote" TEXT,
ADD COLUMN "pinnedNoteAuthor" TEXT,
ADD COLUMN "pinnedNoteUpdatedAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "User" ADD COLUMN "forcedLogoutAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "EventTemplate" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"type" "EventType" NOT NULL,
"metadata" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EventTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "JournalNote" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "JournalNote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Milestone" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"notes" TEXT,
"icon" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Milestone_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vaccination" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"date" TIMESTAMP(3),
"dueDate" TIMESTAMP(3),
"notes" TEXT,
"done" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Vaccination_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"userName" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetId" TEXT,
"familyId" TEXT,
"detail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationProfile" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"molecule" TEXT,
"defaultDose" TEXT,
"unit" TEXT NOT NULL DEFAULT 'mL',
"intervalHours" DOUBLE PRECISION NOT NULL DEFAULT 6,
"minIntervalHours" DOUBLE PRECISION,
"isPreset" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MedicationProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MilkStock" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"volume" DOUBLE PRECISION NOT NULL,
"storedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
"location" TEXT NOT NULL DEFAULT 'fridge',
"notes" TEXT,
"used" BOOLEAN NOT NULL DEFAULT false,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MilkStock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MedicationReminder" (
"id" TEXT NOT NULL,
"babyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"dose" TEXT,
"unit" TEXT,
"intervalHours" DOUBLE PRECISION NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"lastSentAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MedicationReminder_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "EventTemplate" ADD CONSTRAINT "EventTemplate_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalNote" ADD CONSTRAINT "JournalNote_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "JournalNote" ADD CONSTRAINT "JournalNote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Milestone" ADD CONSTRAINT "Milestone_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vaccination" ADD CONSTRAINT "Vaccination_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MilkStock" ADD CONSTRAINT "MilkStock_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MedicationReminder" ADD CONSTRAINT "MedicationReminder_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TYPE "EventType" ADD VALUE 'SYMPTOM';
+1
View File
@@ -154,6 +154,7 @@ enum EventType {
BATH BATH
WALK WALK
TUMMY_TIME TUMMY_TIME
SYMPTOM
} }
model GrowthLog { model GrowthLog {
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
(()=>{"use strict";self.onmessage=async e=>{switch(e.data.type){case"__START_URL_CACHE__":{let t=e.data.url,a=await fetch(t);if(!a.redirected)return(await caches.open("start-url")).put(t,a);return Promise.resolve()}case"__FRONTEND_NAV_CACHE__":{let t=e.data.url,a=await caches.open("pages");if(await a.match(t,{ignoreSearch:!0}))return;let s=await fetch(t);if(!s.ok)return;if(a.put(t,s.clone()),e.data.shouldCacheAggressively&&s.headers.get("Content-Type")?.includes("text/html"))try{let e=await s.text(),t=[],a=await caches.open("static-style-assets"),r=await caches.open("next-static-js-assets"),c=await caches.open("static-js-assets");for(let[s,r]of e.matchAll(/<link.*?href=['"](.*?)['"].*?>/g))/rel=['"]stylesheet['"]/.test(s)&&t.push(a.match(r).then(e=>e?Promise.resolve():a.add(r)));for(let[,a]of e.matchAll(/<script.*?src=['"](.*?)['"].*?>/g)){let e=/\/_next\/static.+\.js$/i.test(a)?r:c;t.push(e.match(a).then(t=>t?Promise.resolve():e.add(a)))}return await Promise.all(t)}catch{}return Promise.resolve()}default:return Promise.resolve()}}})();
File diff suppressed because one or more lines are too long
+69 -1
View File
@@ -15,12 +15,16 @@ import { useSession } from "next-auth/react";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [ const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
{ {
label: "Alimentation & Soins", 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", label: "Sommeil & Activités",
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"], types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
}, },
{
label: "Santé",
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
},
]; ];
interface Event { interface Event {
@@ -64,12 +68,73 @@ function getEventSummary(event: Event): string {
if (event.type === "DIAPER_STOOL") return String(meta.color ?? ""); if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
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 === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) { if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt)); return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
} }
return ""; 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 }) { function QuickNoteWidget({ babyId }: { babyId: string }) {
const qc = useQueryClient(); const qc = useQueryClient();
const todayStr = format(new Date(), "yyyy-MM-dd"); const todayStr = format(new Date(), "yyyy-MM-dd");
@@ -512,6 +577,9 @@ export default function DashboardPage() {
{/* Handoff summary */} {/* Handoff summary */}
<HandoffWidget events={events} userId={session?.user?.id ?? ""} /> <HandoffWidget events={events} userId={session?.user?.id ?? ""} />
{/* Active timers */}
<ActiveTimers events={events} onStop={() => qc.invalidateQueries({ queryKey: ["events"] })} />
{/* Pinned note + Quick note */} {/* Pinned note + Quick note */}
<div className="grid sm:grid-cols-2 gap-4 mb-6"> <div className="grid sm:grid-cols-2 gap-4 mb-6">
<PinnedNoteWidget /> <PinnedNoteWidget />
+64 -8
View File
@@ -11,7 +11,7 @@ import {
import { useBaby } from "@/contexts/baby-context"; import { useBaby } from "@/contexts/baby-context";
import { format, subDays } from "date-fns"; import { format, subDays } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { Plus, Pencil, Trash2 } from "lucide-react"; import { Plus, Pencil, Trash2, FileDown } from "lucide-react";
interface GrowthLog { interface GrowthLog {
id: string; id: string;
@@ -241,6 +241,53 @@ export default function GrowthPage() {
qc.invalidateQueries({ queryKey: ["growth"] }); 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 latest = logs.at(-1);
const rangeOptions: { label: string; value: TimeRange }[] = [ 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> <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> <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{baby?.name}</p>
</div> </div>
<button <div className="flex gap-2">
onClick={() => showForm ? setShowForm(false) : openAddForm()} {logs.length > 0 && (
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" <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">
<Plus className="w-4 h-4" /> <FileDown className="w-4 h-4" />
Mesure PDF
</button> </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> </div>
{/* Add measurement form */} {/* 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 === "DIAPER_STOOL") return String(meta.color ?? "");
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.length > 0 ? tags.join(", ") : "";
}
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) { if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt)); return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
} }
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { searchParams } = new URL(req.url);
const q = searchParams.get("q")?.trim() ?? "";
const babyId = searchParams.get("babyId");
if (!babyId || q.length < 2) return NextResponse.json({ events: [], notes: [] });
const [events, notes] = await Promise.all([
prisma.event.findMany({
where: {
babyId,
OR: [
{ notes: { contains: q, mode: "insensitive" } },
],
},
orderBy: { startedAt: "desc" },
take: 30,
include: { user: { select: { id: true, name: true } } },
}),
prisma.journalNote.findMany({
where: { babyId, content: { contains: q, mode: "insensitive" } },
orderBy: { date: "desc" },
take: 20,
include: { user: { select: { id: true, name: true } } },
}),
]);
return NextResponse.json({ events, notes });
}
+31
View File
@@ -38,6 +38,7 @@ interface FormData {
medUnit?: string; medUnit?: string;
temperature?: string; temperature?: string;
tempUnit?: "C" | "F"; tempUnit?: "C" | "F";
symptomTags?: string[];
} }
interface MedProfile { interface MedProfile {
@@ -116,6 +117,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
medUnit: (meta.unit as string) ?? undefined, medUnit: (meta.unit as string) ?? undefined,
temperature: meta.value != null ? String(meta.value) : undefined, temperature: meta.value != null ? String(meta.value) : undefined,
tempUnit: (meta.unit as "C" | "F") ?? "C", tempUnit: (meta.unit as "C" | "F") ?? "C",
symptomTags: (meta.tags as string[]) ?? [],
}); });
const [timerRunning, setTimerRunning] = useState(false); const [timerRunning, setTimerRunning] = useState(false);
@@ -310,6 +312,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
} }
} }
if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; } if (type === "TEMPERATURE") { metadata.value = form.temperature ? parseFloat(form.temperature) : null; metadata.unit = form.tempUnit; }
if (type === "SYMPTOM") { metadata.tags = form.symptomTags ?? []; }
if (photoUrl) metadata.photo = photoUrl; if (photoUrl) metadata.photo = photoUrl;
const toUTC = (local: string) => local ? new Date(local).toISOString() : null; const toUTC = (local: string) => local ? new Date(local).toISOString() : null;
@@ -784,6 +787,34 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
</div> </div>
)} )}
{/* Symptom tags */}
{type === "SYMPTOM" && (
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-2">Symptômes</label>
<div className="flex flex-wrap gap-2">
{["Fièvre", "Reflux", "Colique", "Éruption", "Toux", "Congestion", "Diarrhée", "Vomissement", "Irritabilité", "Pleurs excessifs"].map((tag) => {
const active = (form.symptomTags ?? []).includes(tag);
return (
<button key={tag} type="button"
onClick={() => setForm((f) => ({
...f,
symptomTags: active
? (f.symptomTags ?? []).filter((t) => t !== tag)
: [...(f.symptomTags ?? []), tag],
}))}
className={`px-3 py-1.5 rounded-full text-xs font-medium border transition ${
active
? "bg-red-100 dark:bg-red-950 border-red-300 dark:border-red-700 text-red-700 dark:text-red-300"
: "bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-red-200 dark:hover:border-red-800"
}`}>
{tag}
</button>
);
})}
</div>
</div>
)}
{/* Time fields — split date + time for iOS Safari compatibility */} {/* Time fields — split date + time for iOS Safari compatibility */}
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
+3
View File
@@ -28,6 +28,7 @@ import {
Milk, Milk,
Pill, Pill,
ImageIcon, ImageIcon,
Search,
} from "lucide-react"; } from "lucide-react";
const LINKS = [ const LINKS = [
@@ -44,6 +45,7 @@ const LINKS = [
{ href: "/vaccinations", label: "Vaccins", Icon: Syringe }, { href: "/vaccinations", label: "Vaccins", Icon: Syringe },
{ href: "/medications", label: "Médicaments", Icon: Pill }, { href: "/medications", label: "Médicaments", Icon: Pill },
{ href: "/reminders", label: "Rappels", Icon: Bell }, { href: "/reminders", label: "Rappels", Icon: Bell },
{ href: "/search", label: "Recherche", Icon: Search },
{ href: "/settings", label: "Réglages", Icon: Settings }, { href: "/settings", label: "Réglages", Icon: Settings },
]; ];
@@ -52,6 +54,7 @@ const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] }, { label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] }, { label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] },
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] }, { label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
{ label: "Outils", hrefs: ["/search"] },
]; ];
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"]; const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
+12 -1
View File
@@ -10,7 +10,8 @@ export type EventType =
| "TEMPERATURE" | "TEMPERATURE"
| "BATH" | "BATH"
| "WALK" | "WALK"
| "TUMMY_TIME"; | "TUMMY_TIME"
| "SYMPTOM";
export const EVENT_CONFIG: Record< export const EVENT_CONFIG: Record<
EventType, EventType,
@@ -145,6 +146,16 @@ export const EVENT_CONFIG: Record<
hasDuration: true, hasDuration: true,
hasTimer: true, hasTimer: true,
}, },
SYMPTOM: {
label: "Symptôme",
icon: "Activity",
color: "#dc2626",
colorClass: "text-red-600",
bgClass: "bg-red-50",
borderClass: "border-red-200",
hasDuration: false,
hasTimer: false,
},
}; };
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[]; export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];