feat: doctor visit log, schedule prediction, chart PDF, natural digest

- Doctor notes: add doctorName, reason, prescriptions, nextAppointmentAt fields (schema + API + UI)
- Dashboard: baby schedule prediction widget (avg nap/bedtime/first-feed over 14 days)
- Growth PDF: embed weight chart as image (SVG→Canvas capture) before data table
- Weekly digest: natural language push notification instead of bullet stats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:46:22 +02:00
parent f470120add
commit f5e0f581af
9 changed files with 336 additions and 36 deletions
+4 -4
View File
@@ -73,10 +73,10 @@
- [x] **Vaccination tracker** — full UI + API, overdue badges, done/upcoming sections
- [x] **Teeth tracker** — visual grid of 20 baby teeth, tap to mark appeared (in progress)
- [ ] **Doctor visit log** — notes, prescriptions, next appointment date
- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
- [x] **Doctor visit log** — notes, prescriptions, next appointment date
- [x] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
- [x] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
- [x] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring
- [ ] **Weight gain on-track indicator** — projected line on growth chart
- [ ] **Contraction timer** — repurpose timer infra for pre-birth use
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "DoctorNote" ADD COLUMN "doctorName" TEXT,
ADD COLUMN "nextAppointmentAt" TIMESTAMP(3),
ADD COLUMN "prescriptions" TEXT,
ADD COLUMN "reason" TEXT;
+4
View File
@@ -190,6 +190,10 @@ model DoctorNote {
userId String
user User @relation(fields: [userId], references: [id])
content String
doctorName String?
reason String?
prescriptions String?
nextAppointmentAt DateTime?
date DateTime @default(now())
createdAt DateTime @default(now())
}
+100
View File
@@ -554,6 +554,13 @@ export default function DashboardPage() {
enabled: !!selectedBaby?.id,
staleTime: 60_000,
});
const { data: scheduleData } = useQuery({
queryKey: ["events-schedule", selectedBaby?.id],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=200&dateFrom=${new Date(Date.now() - 14 * 86400000).toISOString()}`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
staleTime: 300_000,
});
if (familyLoading) {
return (
@@ -594,6 +601,63 @@ export default function DashboardPage() {
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
})();
const schedulePrediction = (() => {
const evs: Event[] = scheduleData?.events ?? [];
if (evs.length < 10) return null;
function avgHourMin(times: number[]): string | null {
if (times.length === 0) return null;
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
const totalMin = Math.round(avgMs / 60000);
const h = Math.floor(totalMin / 60) % 24;
const m = totalMin % 60;
return `${h.toString().padStart(2, "0")}h${m.toString().padStart(2, "0")}`;
}
const byDay: Record<string, Event[]> = {};
for (const e of evs) {
const day = e.startedAt.slice(0, 10);
if (!byDay[day]) byDay[day] = [];
byDay[day].push(e);
}
const napTimes: number[] = [];
const nightTimes: number[] = [];
const firstFeedTimes: number[] = [];
for (const [, dayEvs] of Object.entries(byDay)) {
const nap = dayEvs.find((e) => e.type === "NAP");
if (nap) {
const t = new Date(nap.startedAt);
napTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
}
const night = dayEvs.find((e) => e.type === "NIGHT_SLEEP");
if (night) {
const t = new Date(night.startedAt);
nightTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
}
const feeds = dayEvs
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
if (feeds.length > 0) {
const t = new Date(feeds[0].startedAt);
const minuteOfDay = t.getHours() * 60 + t.getMinutes();
if (minuteOfDay > 240) {
firstFeedTimes.push(minuteOfDay * 60000);
}
}
}
const nap = avgHourMin(napTimes);
const night = avgHourMin(nightTimes);
const firstFeed = avgHourMin(firstFeedTimes);
if (!nap && !night && !firstFeed) return null;
return { nap, night, firstFeed };
})();
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
{/* Page header */}
@@ -670,6 +734,42 @@ export default function DashboardPage() {
</div>
)}
{schedulePrediction && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Planning habituel</p>
<div className="flex flex-wrap gap-3">
{schedulePrediction.firstFeed && (
<div className="flex items-center gap-2">
<span className="text-base">🍼</span>
<div>
<p className="text-[10px] text-slate-400 dark:text-slate-500">1er repas</p>
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.firstFeed}</p>
</div>
</div>
)}
{schedulePrediction.nap && (
<div className="flex items-center gap-2">
<span className="text-base">😴</span>
<div>
<p className="text-[10px] text-slate-400 dark:text-slate-500">Sieste</p>
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.nap}</p>
</div>
</div>
)}
{schedulePrediction.night && (
<div className="flex items-center gap-2">
<span className="text-base">🌙</span>
<div>
<p className="text-[10px] text-slate-400 dark:text-slate-500">Coucher</p>
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.night}</p>
</div>
</div>
)}
</div>
<p className="text-[10px] text-slate-300 dark:text-slate-600 mt-2">Moyenne sur 14 jours</p>
</div>
)}
{/* Empty state for new users */}
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
+91 -2
View File
@@ -12,6 +12,10 @@ interface DoctorNote {
id: string;
content: string;
date: string;
doctorName?: string | null;
reason?: string | null;
prescriptions?: string | null;
nextAppointmentAt?: string | null;
createdAt: string;
userId: string;
user: { id: string; name: string };
@@ -29,8 +33,14 @@ export default function DoctorNotesPage() {
const today = new Date().toISOString().slice(0, 10);
const [content, setContent] = useState("");
const [date, setDate] = useState(today);
const [doctorName, setDoctorName] = useState("");
const [reason, setReason] = useState("");
const [prescriptions, setPrescriptions] = useState("");
const [nextAppointmentAt, setNextAppointmentAt] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
queryKey: ["doctor-notes", selectedBaby?.id],
queryFn: () =>
@@ -39,7 +49,7 @@ export default function DoctorNotesPage() {
});
const createMutation = useMutation({
mutationFn: (body: { babyId: string; content: string; date: string }) =>
mutationFn: (body: { babyId: string; content: string; date: string; doctorName?: string; reason?: string; prescriptions?: string; nextAppointmentAt?: string }) =>
fetch("/api/doctor-notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -55,6 +65,10 @@ export default function DoctorNotesPage() {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
setContent("");
setDate(today);
setDoctorName("");
setReason("");
setPrescriptions("");
setNextAppointmentAt("");
setFormError(null);
},
onError: (err: Error) => {
@@ -78,7 +92,15 @@ export default function DoctorNotesPage() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby?.id || !content.trim() || !date) return;
createMutation.mutate({ babyId: selectedBaby.id, content: content.trim(), date });
createMutation.mutate({
babyId: selectedBaby.id,
content: content.trim(),
date,
doctorName: doctorName || undefined,
reason: reason || undefined,
prescriptions: prescriptions || undefined,
nextAppointmentAt: nextAppointmentAt || undefined,
});
}
if (familyLoading) {
@@ -135,6 +157,53 @@ export default function DoctorNotesPage() {
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Médecin <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="text"
value={doctorName}
onChange={(e) => setDoctorName(e.target.value)}
placeholder="Dr. Dupont"
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Motif <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Visite de routine, fièvre..."
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Prescriptions <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<textarea
value={prescriptions}
onChange={(e) => setPrescriptions(e.target.value)}
rows={2}
placeholder="Doliprane 2.4% si fièvre..."
className={inputCls + " resize-none"}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
Prochain rendez-vous <span className="text-slate-400 font-normal">(optionnel)</span>
</label>
<input
type="date"
value={nextAppointmentAt}
onChange={(e) => setNextAppointmentAt(e.target.value)}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
Observation
@@ -214,6 +283,26 @@ export default function DoctorNotesPage() {
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
{note.content}
</p>
{note.doctorName && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
<span className="font-medium">Médecin :</span> {note.doctorName}
</p>
)}
{note.reason && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
<span className="font-medium">Motif :</span> {note.reason}
</p>
)}
{note.prescriptions && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 whitespace-pre-line">
<span className="font-medium">Prescriptions :</span> {note.prescriptions}
</p>
)}
{note.nextAppointmentAt && (
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-1 font-medium">
📅 Prochain RDV : {format(new Date(note.nextAppointmentAt), "d MMMM yyyy", { locale: fr })}
</p>
)}
</div>
))}
</div>
+48 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
@@ -168,6 +168,7 @@ export default function GrowthPage() {
setShowForm(true);
}
const [saving, setSaving] = useState(false);
const chartRef = useRef<HTMLDivElement>(null);
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
@@ -241,7 +242,38 @@ export default function GrowthPage() {
qc.invalidateQueries({ queryKey: ["growth"] });
}
function exportPDF() {
async function captureChartAsImage(): Promise<string | null> {
if (!chartRef.current) return null;
const svg = chartRef.current.querySelector("svg");
if (!svg) return null;
try {
const svgData = new XMLSerializer().serializeToString(svg);
const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
return await new Promise<string>((resolve) => {
const img = new window.Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const scale = 2;
canvas.width = svg.clientWidth * scale;
canvas.height = svg.clientHeight * scale;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
resolve(canvas.toDataURL("image/png"));
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null as unknown as string); };
img.src = url;
});
} catch {
return null;
}
}
async function exportPDF() {
function getPercentileBand(ageWeeks: number, weightKg: number): string {
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
if (weightKg < values[0]) return "< P3";
@@ -251,6 +283,7 @@ export default function GrowthPage() {
return "> P97";
}
const chartImg = await captureChartAsImage();
import("jspdf").then(({ default: jsPDF }) => {
const doc = new jsPDF();
const pageW = doc.internal.pageSize.getWidth();
@@ -267,7 +300,18 @@ export default function GrowthPage() {
// Table header
const COL = [14, 55, 95, 130, 155];
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
let y = 46;
let y: number;
if (chartImg) {
const imgWidth = pageW - 28;
const imgHeight = 70;
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text("Courbe de poids", 14, 42);
doc.addImage(chartImg, "PNG", 14, 45, imgWidth, imgHeight);
y = 45 + imgHeight + 10;
} else {
y = 46;
}
doc.setFillColor(238, 242, 255);
doc.rect(14, y - 5, pageW - 28, 9, "F");
@@ -419,7 +463,7 @@ export default function GrowthPage() {
<div className="space-y-4">
{/* Weight + WHO */}
{weightData.length > 0 && (
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<div ref={chartRef} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
+27 -15
View File
@@ -47,11 +47,9 @@ export async function POST(req: Request) {
for (const family of families) {
if (family.users.length === 0) continue;
const lines: string[] = [`📊 Résumé semaine — ${family.name}`, ""];
const lines: string[] = [];
for (const baby of family.babies) {
lines.push(`👶 ${baby.name}`);
const events = await prisma.event.findMany({
where: { babyId: baby.id, startedAt: { gte: since } },
orderBy: { startedAt: "asc" },
@@ -59,36 +57,50 @@ export async function POST(req: Request) {
// Feedings
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
if (feeds.length > 0) {
const perDay = (feeds.length / 7).toFixed(1);
lines.push(`🍼 Repas: ${feeds.length} au total (${perDay}/j)`);
}
// Sleep
const sleepEvents = events.filter(
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
);
if (sleepEvents.length > 0) {
const totalSleepMs = sleepEvents.reduce((acc, e) => {
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
}, 0);
const avgPerDay = totalSleepMs / 7;
lines.push(`😴 Sommeil: ${fmtMs(totalSleepMs)} total, ~${fmtMs(avgPerDay)}/j`);
}
// Diapers
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
if (diapers.length > 0) {
lines.push(`👶 Couches: ${diapers.length} (${(diapers.length / 7).toFixed(1)}/j)`);
const parts: string[] = [];
if (feeds.length > 0) {
const perDay = Math.round(feeds.length / 7);
parts.push(`${feeds.length} repas (${perDay}/j)`);
}
if (sleepEvents.length > 0) {
const avgPerDay = totalSleepMs / 7;
parts.push(`${fmtMs(avgPerDay)}/j de sommeil`);
}
if (diapers.length > 0) {
const perDay = Math.round(diapers.length / 7);
parts.push(`${diapers.length} couches (${perDay}/j)`);
}
let babyLine = `👶 ${baby.name} cette semaine : `;
if (parts.length > 0) {
babyLine += parts.join(", ") + ".";
} else {
babyLine += "peu d'événements enregistrés.";
}
lines.push(babyLine);
// Weight (latest growth log in the period)
const latestWeight = await prisma.growthLog.findFirst({
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
orderBy: { date: "desc" },
});
if (latestWeight?.weight) {
lines.push(`⚖️ Poids: ${latestWeight.weight} kg`);
lines.push(`⚖️ Poids : ${(latestWeight.weight / 1000).toFixed(3)} kg`);
}
lines.push("");
@@ -98,7 +110,7 @@ export async function POST(req: Request) {
await Promise.allSettled(
family.users.map((u) =>
sendPushover(u.pushoverUserKey!, message, "Grow — Résumé hebdomadaire")
sendPushover(u.pushoverUserKey!, message, `📊 Résumé hebdo${family.name}`)
)
);
sent += family.users.length;
+42
View File
@@ -29,3 +29,45 @@ export async function DELETE(
await prisma.doctorNote.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userRole = (session.user as { role?: string }).role;
if (userRole !== "DOCTOR") {
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
}
const { id } = await params;
const note = await prisma.doctorNote.findUnique({ where: { id } });
if (!note) {
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
}
if (note.userId !== session.user.id) {
return NextResponse.json({ error: "Vous ne pouvez modifier que vos propres observations" }, { status: 403 });
}
const body = await req.json();
const updated = await prisma.doctorNote.update({
where: { id },
data: {
...(body.content !== undefined && { content: body.content }),
...(body.date !== undefined && { date: new Date(body.date) }),
doctorName: body.doctorName || null,
reason: body.reason || null,
prescriptions: body.prescriptions || null,
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
},
include: { user: { select: { id: true, name: true } } },
});
return NextResponse.json(updated);
}
+4
View File
@@ -45,6 +45,10 @@ export async function POST(req: Request) {
userId: session.user.id,
content,
date: noteDate,
doctorName: body.doctorName || null,
reason: body.reason || null,
prescriptions: body.prescriptions || null,
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
},
include: { user: { select: { id: true, name: true } } },
});