From cd3efa98fcf7ac89269c1982b46c9f5a7b37cc2f Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 21:51:32 +0200 Subject: [PATCH] feat: teeth tracker, feeding analysis, sidebar groups, timeline polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Teeth tracker: Tooth model + migration, API routes (GET/POST upsert/DELETE), page at /teeth with 20-tooth grid, progress bar, inline mark/unmark flow - Dashboard feeding analysis: avg interval + last feed + next expected time widget - Fix dashboard baby?.name crash (selectedBaby?.name) UI/UX: - Sidebar: grouped into Suivi / Santé / Vie du bébé / Outils sections - Nav: active highlight works on child routes (startsWith) - Timeline: filter chips sticky on mobile; skeleton cards replace spinner - Stats: 500-event limit warning banner - Medications: 3 states — "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber) - Notes: skip auto-save on empty/whitespace blur Co-Authored-By: Claude Sonnet 4.6 --- IMPROVEMENTS.md | 21 +- .../20260615195041_add_teeth/migration.sql | 18 + prisma/schema.prisma | 14 + src/app/(app)/dashboard/page.tsx | 84 ++++- src/app/(app)/notes/page.tsx | 4 +- src/app/(app)/stats/page.tsx | 7 + src/app/(app)/teeth/page.tsx | 317 ++++++++++++++++++ src/app/(app)/timeline/page.tsx | 24 +- src/app/api/teeth/[code]/route.ts | 20 ++ src/app/api/teeth/route.ts | 48 +++ src/components/nav.tsx | 64 +++- 11 files changed, 596 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/20260615195041_add_teeth/migration.sql create mode 100644 src/app/(app)/teeth/page.tsx create mode 100644 src/app/api/teeth/[code]/route.ts create mode 100644 src/app/api/teeth/route.ts diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 32da416..ee1d3ea 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -12,6 +12,8 @@ ### Dashboard - [x] **Empty state for new users** — onboarding callout when no events today - [x] **Active timers show which baby** — baby name badge below event label +- [x] **Medication overdue** — 3 states: "Dans Xh" (orange), "Disponible" (green), "En retard de Xh" (amber) +- [x] **Feeding pattern analysis widget** — avg interval + last feed + next expected (in progress) ### Growth - [x] **Single-measurement chart** — shows point + WHO curves (was blank until 2+ points) @@ -23,10 +25,13 @@ ### Stats - [x] **Heatmap legend** — shows actual counts (0 1 2 3 4+) - [x] **Sleep timeline label** — "Fenêtre 19h – 11h" +- [x] **Event limit warning** — banner when 500-event limit hit - [ ] **Chart zero-data gaps** — distinguish "no data" vs "zero events" visually ### Timeline - [x] **Filter state sticky** — persisted in URL param `?type=...` +- [x] **Filter chips sticky on mobile** — `sticky top-0 z-10` container +- [x] **Loading skeleton** — skeleton cards replace spinner on first render - [x] **Inline note save feedback** — ✓ flash after blur - [x] **Photo thumbnail clickable** — opens in new tab - [x] **Select mode indicator** — toggle shows "Annuler" when active @@ -38,11 +43,12 @@ ### Search - [x] **Results clickable** — events link to `/timeline?date=...`, notes to `/notes?date=...` - [x] **Result count header** — "X événements · Y notes" -- [ ] **Date/type filter chips** on search results +- [x] **Date/type filter chips** on search results ### Notes - [x] **Unsaved indicator** — amber dot while dirty, green ✓ after save - [x] **Date jump input** — invisible date input overlaid on label, opens native calendar +- [x] **Empty blur fix** — skip save on empty/whitespace content ### Milestones - [x] **Sort by date** within month group @@ -60,19 +66,14 @@ ### Navigation - [x] **Baby switcher `aria-label`** — added to nav select -- [ ] **Unsaved state warning** — prompt before leaving dirty notes page - -## Remaining - -- [ ] **Search date/type filter chips** — filter search results by date range or event type -- [ ] **Stats chart zero-data gaps** — distinguish "no data" vs "zero events" visually -- [ ] **Notes unsaved state nav warning** — prompt before leaving dirty notes page +- [x] **Active highlight on child routes** — uses `startsWith` instead of exact match +- [x] **Sidebar grouped** — sections: Suivi / Santé / Vie du bébé / Outils (replaces flat 14-item list) ## Feature Backlog -- [ ] **Vaccination tracker** — dates, vaccine name, lot, next-due reminder + push alert +- [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 -- [ ] **Teeth tracker** — visual mouth diagram, mark appearance 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 diff --git a/prisma/migrations/20260615195041_add_teeth/migration.sql b/prisma/migrations/20260615195041_add_teeth/migration.sql new file mode 100644 index 0000000..e4e1435 --- /dev/null +++ b/prisma/migrations/20260615195041_add_teeth/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "Tooth" ( + "id" TEXT NOT NULL, + "babyId" TEXT NOT NULL, + "code" TEXT NOT NULL, + "appearedAt" TIMESTAMP(3) NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tooth_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tooth_babyId_code_key" ON "Tooth"("babyId", "code"); + +-- AddForeignKey +ALTER TABLE "Tooth" ADD CONSTRAINT "Tooth_babyId_fkey" FOREIGN KEY ("babyId") REFERENCES "Baby"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d1c41fb..4ecbbbc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,6 +136,7 @@ model Baby { vaccinations Vaccination[] medicationReminders MedicationReminder[] milkStocks MilkStock[] + teeth Tooth[] createdAt DateTime @default(now()) } @@ -279,3 +280,16 @@ model MedicationReminder { lastSentAt DateTime? createdAt DateTime @default(now()) } + +model Tooth { + id String @id @default(cuid()) + babyId String + baby Baby @relation(fields: [babyId], references: [id], onDelete: Cascade) + code String + appearedAt DateTime + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([babyId, code]) +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 813d817..bca3c09 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -511,9 +511,9 @@ export default function DashboardPage() { useEffect(() => { const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE"); - checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name); + checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name); const id = setInterval(() => { - checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), baby?.name); + checkFeedAlert(lastFeed ? new Date(lastFeed.startedAt) : null, getThresholds(), selectedBaby?.name); }, 5 * 60 * 1000); return () => clearInterval(id); }, [events]); @@ -540,6 +540,21 @@ export default function DashboardPage() { refetchInterval: 30_000, }); + const { data: feedData } = useQuery({ + queryKey: ["events-feeds", selectedBaby?.id], + queryFn: () => + fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + staleTime: 60_000, + }); + const { data: bottleData } = useQuery({ + queryKey: ["events-bottles", selectedBaby?.id], + queryFn: () => + fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BOTTLE`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + staleTime: 60_000, + }); + if (familyLoading) { return (
@@ -559,6 +574,26 @@ export default function DashboardPage() { ); } + // Feed pattern analysis + const allFeeds: Event[] = [ + ...(feedData?.events ?? []), + ...(bottleData?.events ?? []), + ].sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime()); + + const feedAnalysis = (() => { + const recent = allFeeds.slice(0, 20); + if (recent.length < 2) return null; + const times = recent.map((e) => new Date(e.startedAt).getTime()).sort((a, b) => b - a); + const intervals = times.slice(0, -1).map((t, i) => t - times[i + 1]); + const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const avgH = Math.floor(avgMs / 3600000); + const avgM = Math.round((avgMs % 3600000) / 60000); + const lastFeedTime = new Date(times[0]); + const nextExpected = new Date(times[0] + avgMs); + const untilNextMs = nextExpected.getTime() - Date.now(); + return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs }; + })(); + return (
{/* Page header */} @@ -601,6 +636,40 @@ export default function DashboardPage() { })}
+ {feedAnalysis && ( +
+

Repas

+
+
+

Intervalle moy.

+

+ {feedAnalysis.avgH}h{feedAnalysis.avgM.toString().padStart(2, "0")} +

+
+
+

Dernier repas

+

+ {format(feedAnalysis.lastFeedTime, "HH:mm")} +

+
+
+

Prochain estimé

+

+ {format(feedAnalysis.nextExpected, "HH:mm")} +

+ {feedAnalysis.untilNextMs > 0 && ( +

+ dans {Math.floor(feedAnalysis.untilNextMs / 3600000)}h{Math.floor((feedAnalysis.untilNextMs % 3600000) / 60000).toString().padStart(2, "0")} +

+ )} + {feedAnalysis.untilNextMs < 0 && ( +

dépassé

+ )} +
+
+
+ )} + {/* Empty state for new users */} {todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
@@ -658,14 +727,17 @@ export default function DashboardPage() { const nextAt = new Date(new Date(intake.startedAt).getTime() + p.intervalHours * 3600000); const waitMs = nextAt.getTime() - Date.now(); const tooSoon = waitMs > 0; + const overdue = !tooSoon && waitMs < -3600000; const h = Math.floor(Math.abs(waitMs) / 3600000); const m = Math.floor((Math.abs(waitMs) % 3600000) / 60000); const timeLabel = h > 0 ? `${h}h${m.toString().padStart(2, "0")}` : `${m}min`; return (
-
+
{tooSoon - ? + ? + : overdue + ? : }
@@ -675,7 +747,9 @@ export default function DashboardPage() {
{tooSoon ? ( -

Dans {timeLabel}

+

Dans {timeLabel}

+ ) : overdue ? ( +

En retard de {timeLabel}

) : (

Disponible

)} diff --git a/src/app/(app)/notes/page.tsx b/src/app/(app)/notes/page.tsx index ad17985..d637cf8 100644 --- a/src/app/(app)/notes/page.tsx +++ b/src/app/(app)/notes/page.tsx @@ -162,11 +162,9 @@ export default function NotesPage() { async function handleBlur() { if (isReadonly) return; const trimmed = draftContent.trim(); - // Only auto-save if content changed from what's stored + if (!trimmed) return; const stored = currentNote?.content ?? ""; if (trimmed === stored) return; - // Don't auto-save empty content if there's no existing note - if (!trimmed && !currentNote) return; await handleSave(); } diff --git a/src/app/(app)/stats/page.tsx b/src/app/(app)/stats/page.tsx index 247e921..e2bad7a 100644 --- a/src/app/(app)/stats/page.tsx +++ b/src/app/(app)/stats/page.tsx @@ -338,6 +338,7 @@ export default function StatsPage() { }); const events: Event[] = eventsData?.events ?? []; + const hitLimit = events.length >= 500; const weeklyData = buildWeeklyData(events, days); const durationTrend = buildDurationTrend(events, Math.min(days, 30)); const feedInterval = computeFeedInterval(events); @@ -377,6 +378,12 @@ export default function StatsPage() {
+ {hitLimit && ( +
+ Données limitées aux 500 derniers événements de la période — les graphiques peuvent être incomplets. +
+ )} + {/* Today summary */}
{[ diff --git a/src/app/(app)/teeth/page.tsx b/src/app/(app)/teeth/page.tsx new file mode 100644 index 0000000..ff7ce20 --- /dev/null +++ b/src/app/(app)/teeth/page.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useBaby } from "@/contexts/baby-context"; +import { format } from "date-fns"; +import { fr } from "date-fns/locale"; +import { Check, Trash2, X } from "lucide-react"; + +interface Tooth { + id: string; + babyId: string; + code: string; + appearedAt: string; + notes: string | null; +} + +const UPPER_CODES = ["u5l", "u4l", "u3l", "u2l", "u1l", "u1r", "u2r", "u3r", "u4r", "u5r"]; +const LOWER_CODES = ["l5l", "l4l", "l3l", "l2l", "l1l", "l1r", "l2r", "l3r", "l4r", "l5r"]; + +const TOOTH_NAMES: Record = { + "1": "Incisive centrale", + "2": "Incisive latérale", + "3": "Canine", + "4": "Première molaire", + "5": "Deuxième molaire", +}; + +const ERUPTION_AGES: Record = { + "1": "6–12 mois", + "2": "9–16 mois", + "3": "16–23 mois", + "4": "14–18 mois", + "5": "23–31 mois", +}; + +function getToothNumber(code: string): string { + // code is like "u1l", "l3r" — digit is at index 1 + return code[1]; +} + +function getToothLabel(code: string): string { + const num = getToothNumber(code); + const jaw = code[0] === "u" ? "sup." : "inf."; + const side = code[2] === "l" ? "gauche" : "droite"; + return `${TOOTH_NAMES[num]} ${jaw} ${side}`; +} + +const inputCls = + "w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"; + +type ActiveState = + | { type: "form"; code: string; date: string; notes: string } + | { type: "detail"; code: string } + | null; + +export default function TeethPage() { + const qc = useQueryClient(); + const { selectedBaby: baby } = useBaby(); + const [active, setActive] = useState(null); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(null); + + const { data: teeth = [] } = useQuery({ + queryKey: ["teeth", baby?.id], + queryFn: () => + fetch(`/api/teeth?babyId=${baby!.id}`).then((r) => r.json()), + enabled: !!baby?.id, + }); + + const appearedCodes = new Set(teeth.map((t) => t.code)); + const count = appearedCodes.size; + + function getToothData(code: string): Tooth | undefined { + return teeth.find((t) => t.code === code); + } + + function handleToothClick(code: string) { + if (appearedCodes.has(code)) { + setActive(active?.type === "detail" && active.code === code ? null : { type: "detail", code }); + } else { + const today = format(new Date(), "yyyy-MM-dd"); + setActive( + active?.type === "form" && active.code === code + ? null + : { type: "form", code, date: today, notes: "" } + ); + } + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + if (!baby || active?.type !== "form") return; + setSaving(true); + await fetch("/api/teeth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + babyId: baby.id, + code: active.code, + appearedAt: new Date(active.date).toISOString(), + notes: active.notes || null, + }), + }); + qc.invalidateQueries({ queryKey: ["teeth", baby.id] }); + setSaving(false); + setActive(null); + } + + async function handleDelete(code: string) { + if (!baby) return; + setDeleting(code); + await fetch(`/api/teeth/${code}?babyId=${baby.id}`, { method: "DELETE" }); + qc.invalidateQueries({ queryKey: ["teeth", baby.id] }); + setDeleting(null); + setActive(null); + } + + function ToothSquare({ code }: { code: string }) { + const appeared = appearedCodes.has(code); + const num = getToothNumber(code); + const isActive = active !== null && active.code === code; + + return ( + + ); + } + + const activeToothLabel = active ? getToothLabel(active.code) : ""; + const activeToothNum = active ? getToothNumber(active.code) : ""; + + return ( +
+ {/* Header */} +
+

Dents

+

{baby?.name}

+
+ + {/* Progress */} +
+
+ + {count} / 20 dents apparues + + + {Math.round((count / 20) * 100)} % + +
+
+
+
+
+ + {/* Diagram */} +
+ {/* Upper jaw label */} +

+ Mâchoire supérieure +

+
+ {UPPER_CODES.map((code) => ( + + ))} +
+ +
+ +
+ {LOWER_CODES.map((code) => ( + + ))} +
+ {/* Lower jaw label */} +

+ Mâchoire inférieure +

+
+ + {/* Legend */} +
+ + + Non apparue + + + + Apparue + +
+ + {/* Inline panel for form or detail */} + {active && ( +
+
+
+

+ {activeToothLabel} +

+

+ Éruption typique : {ERUPTION_AGES[activeToothNum]} +

+
+ +
+ + {active.type === "form" ? ( +
+
+ + + setActive((prev) => + prev?.type === "form" ? { ...prev, date: e.target.value } : prev + ) + } + className={inputCls} + /> +
+
+ + + setActive((prev) => + prev?.type === "form" ? { ...prev, notes: e.target.value } : prev + ) + } + placeholder="Observations..." + className={inputCls} + /> +
+
+ + +
+
+ ) : ( + (() => { + const tooth = getToothData(active.code); + if (!tooth) return null; + return ( +
+

+ Apparue le{" "} + + {format(new Date(tooth.appearedAt), "d MMMM yyyy", { locale: fr })} + +

+ {tooth.notes && ( +

{tooth.notes}

+ )} + +
+ ); + })() + )} +
+ )} +
+ ); +} diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx index f4d7927..cf2f199 100644 --- a/src/app/(app)/timeline/page.tsx +++ b/src/app/(app)/timeline/page.tsx @@ -160,7 +160,8 @@ export default function TimelinePage() {
{/* Filter chips */} -
+
+
))}
+
{selectMode && ( @@ -210,8 +212,24 @@ export default function TimelinePage() { )} {isLoading && ( -
-
+
+ {[1, 2, 3].map((g) => ( +
+
+
+ {[1, 2, 3, 4].map((r) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ ))}
)} diff --git a/src/app/api/teeth/[code]/route.ts b/src/app/api/teeth/[code]/route.ts new file mode 100644 index 0000000..ceca255 --- /dev/null +++ b/src/app/api/teeth/[code]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function DELETE(req: Request, { params }: { params: Promise<{ code: string }> }) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const { code } = await params; + const { searchParams } = new URL(req.url); + const babyId = searchParams.get("babyId"); + + if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + + await prisma.tooth.delete({ + where: { babyId_code: { babyId, code } }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/teeth/route.ts b/src/app/api/teeth/route.ts new file mode 100644 index 0000000..a696975 --- /dev/null +++ b/src/app/api/teeth/route.ts @@ -0,0 +1,48 @@ +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 babyId = searchParams.get("babyId"); + + if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + + const teeth = await prisma.tooth.findMany({ + where: { babyId }, + orderBy: { appearedAt: "asc" }, + }); + + return NextResponse.json(teeth); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + + const body = await req.json(); + const { babyId, code, appearedAt, notes } = body; + + if (!babyId || !code || !appearedAt) { + return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); + } + + const tooth = await prisma.tooth.upsert({ + where: { babyId_code: { babyId, code } }, + update: { + appearedAt: new Date(appearedAt), + notes: notes ?? null, + }, + create: { + babyId, + code, + appearedAt: new Date(appearedAt), + notes: notes ?? null, + }, + }); + + return NextResponse.json(tooth, { status: 201 }); +} diff --git a/src/components/nav.tsx b/src/components/nav.tsx index d6bfee4..7b84bb7 100644 --- a/src/components/nav.tsx +++ b/src/components/nav.tsx @@ -29,6 +29,7 @@ import { Pill, ImageIcon, Search, + Smile, } from "lucide-react"; const LINKS = [ @@ -43,6 +44,7 @@ const LINKS = [ { href: "/notes", label: "Notes", Icon: NotebookPen }, { href: "/doctor-notes", label: "Médecin", Icon: Stethoscope }, { href: "/vaccinations", label: "Vaccins", Icon: Syringe }, + { href: "/teeth", label: "Dents", Icon: Smile }, { href: "/medications", label: "Médicaments", Icon: Pill }, { href: "/reminders", label: "Rappels", Icon: Bell }, { href: "/search", label: "Recherche", Icon: Search }, @@ -52,7 +54,7 @@ const LINKS = [ // Groups shown in the mobile "More" drawer const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [ { label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] }, - { label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] }, + { label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/teeth", "/doctor-notes"] }, { label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] }, { label: "Outils", hrefs: ["/search"] }, ]; @@ -158,9 +160,11 @@ export function SidebarNav() {
)} -