From f5e0f581afdd228ee0b0a46cd2b8c54c6bb210a3 Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 22:46:22 +0200 Subject: [PATCH] feat: doctor visit log, schedule prediction, chart PDF, natural digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- IMPROVEMENTS.md | 8 +- .../migration.sql | 5 + prisma/schema.prisma | 20 ++-- src/app/(app)/dashboard/page.tsx | 100 ++++++++++++++++++ src/app/(app)/doctor-notes/page.tsx | 93 +++++++++++++++- src/app/(app)/growth/page.tsx | 52 ++++++++- src/app/api/cron/weekly-summary/route.ts | 48 +++++---- src/app/api/doctor-notes/[id]/route.ts | 42 ++++++++ src/app/api/doctor-notes/route.ts | 4 + 9 files changed, 336 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/20260615203958_enhance_doctor_note/migration.sql diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index ee1d3ea..a16bf99 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -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 diff --git a/prisma/migrations/20260615203958_enhance_doctor_note/migration.sql b/prisma/migrations/20260615203958_enhance_doctor_note/migration.sql new file mode 100644 index 0000000..a940279 --- /dev/null +++ b/prisma/migrations/20260615203958_enhance_doctor_note/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ecbbbc..5434472 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -184,14 +184,18 @@ model GrowthLog { } model DoctorNote { - id String @id @default(cuid()) - babyId String - baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade) - userId String - user User @relation(fields: [userId], references: [id]) - content String - date DateTime @default(now()) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + babyId String + baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade) + 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()) } model JournalNote { diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 67af0e4..42718cf 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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 = {}; + 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 (
{/* Page header */} @@ -670,6 +734,42 @@ export default function DashboardPage() {
)} + {schedulePrediction && ( +
+

Planning habituel

+
+ {schedulePrediction.firstFeed && ( +
+ 🍼 +
+

1er repas

+

{schedulePrediction.firstFeed}

+
+
+ )} + {schedulePrediction.nap && ( +
+ 😴 +
+

Sieste

+

{schedulePrediction.nap}

+
+
+ )} + {schedulePrediction.night && ( +
+ 🌙 +
+

Coucher

+

{schedulePrediction.night}

+
+
+ )} +
+

Moyenne sur 14 jours

+
+ )} + {/* Empty state for new users */} {todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
diff --git a/src/app/(app)/doctor-notes/page.tsx b/src/app/(app)/doctor-notes/page.tsx index 077e13d..4dadac3 100644 --- a/src/app/(app)/doctor-notes/page.tsx +++ b/src/app/(app)/doctor-notes/page.tsx @@ -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(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({ 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" />
+
+ + setDoctorName(e.target.value)} + placeholder="Dr. Dupont" + className={inputCls} + /> +
+
+ + setReason(e.target.value)} + placeholder="Visite de routine, fièvre..." + className={inputCls} + /> +
+
+ +