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 (
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é
+ )} +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() {{baby?.name}
++ Mâchoire supérieure +
++ Mâchoire inférieure +
++ {activeToothLabel} +
++ Éruption typique : {ERUPTION_AGES[activeToothNum]} +
++ Apparue le{" "} + + {format(new Date(tooth.appearedAt), "d MMMM yyyy", { locale: fr })} + +
+ {tooth.notes && ( +{tooth.notes}
+ )} + +