From e9c8d7e97e0299246f7c4f005d2127db7fa4cb3e Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Sat, 13 Jun 2026 16:31:47 +0200 Subject: [PATCH] feat: daily digest, photo timeline, milk expiry cron, batch delete, duration chart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/cron/daily-digest: last feed/diaper, 24h sleep total, next med due - /photos: photo grid grouped by day, full-screen lightbox with prev/next - /api/cron/milk-expiry: push alert for lots expiring within 24h - Timeline: select mode with checkboxes, confirm + bulk DELETE - Stats: avg NAP/NIGHT_SLEEP duration trend dual-line chart (30d) - Nav: Photos link added under "Vie du bΓ©bΓ©" drawer group Co-Authored-By: Claude Sonnet 4.6 --- FEATURES.md | 10 +- src/app/(app)/photos/page.tsx | 171 +++++++++++++++++++++++++ src/app/(app)/stats/page.tsx | 47 +++++++ src/app/(app)/timeline/page.tsx | 77 ++++++++++- src/app/api/cron/daily-digest/route.ts | 121 +++++++++++++++++ src/app/api/cron/milk-expiry/route.ts | 83 ++++++++++++ src/components/nav.tsx | 4 +- stack.env | 38 ++++++ 8 files changed, 539 insertions(+), 12 deletions(-) create mode 100644 src/app/(app)/photos/page.tsx create mode 100644 src/app/api/cron/daily-digest/route.ts create mode 100644 src/app/api/cron/milk-expiry/route.ts create mode 100644 stack.env diff --git a/FEATURES.md b/FEATURES.md index 29a5d1e..ead2f06 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -67,13 +67,13 @@ πŸ”² Data retention policy β€” auto-archive events older than N months πŸ”² Offline support / PWA install prompt πŸ”² Multi-language UI (FR/EN toggle) -πŸ”² Daily digest push notification β€” morning summary: last feed, last diaper, sleep total, next medication due +βœ… Daily digest push notification β€” /api/cron/daily-digest: last feed, last diaper, sleep total, next medication due +βœ… Baby photo timeline β€” /photos, grid by day, full-screen lightbox with prev/next nav +βœ… Milk stock expiry alert push β€” /api/cron/milk-expiry: lots expiring within 24h, grouped by family +βœ… Batch delete events β€” "SΓ©lectionner" mode in timeline, checkboxes per row, confirm + bulk delete +βœ… Event duration history chart β€” avg NAP/NIGHT_SLEEP duration per day, dual-line chart in stats πŸ”² Feeding session heatmap β€” 7Γ—24 grid showing feed density by hour/day of week πŸ”² Diaper tracker streak β€” consecutive days with at least N diapers (regression alert) -πŸ”² Baby photo timeline β€” dedicated photo journal page, one photo per day, full-screen gallery πŸ”² Quick note from dashboard β€” one-tap free text note without navigating to /notes -πŸ”² Event duration history chart β€” average NAP/NIGHT_SLEEP duration over last 30 days (trend line) -πŸ”² Batch delete events β€” multi-select in timeline + delete all selected πŸ”² Caregiver handoff summary β€” auto-generated "since last login" digest shown on dashboard login -πŸ”² Milk stock expiry alert push β€” notify when a lot expires within 24h (daily cron check) πŸ”² Weight percentile push alert β€” notify when weight drops below/above a configurable WHO percentile band diff --git a/src/app/(app)/photos/page.tsx b/src/app/(app)/photos/page.tsx new file mode 100644 index 0000000..1e3e90c --- /dev/null +++ b/src/app/(app)/photos/page.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useBaby } from "@/contexts/baby-context"; +import { EVENT_CONFIG, EventType } from "@/lib/event-config"; +import { format, isToday, isYesterday } from "date-fns"; +import { fr } from "date-fns/locale"; +import { X, ChevronLeft, ChevronRight, Camera } from "lucide-react"; +import Image from "next/image"; + +interface PhotoEvent { + id: string; + type: EventType; + startedAt: string; + notes?: string; + metadata: { photo: string }; + user: { name: string }; +} + +function dayLabel(key: string): string { + const d = new Date(key); + if (isToday(d)) return "Aujourd'hui"; + if (isYesterday(d)) return "Hier"; + return format(d, "EEEE d MMMM yyyy", { locale: fr }); +} + +export default function PhotosPage() { + const { selectedBaby } = useBaby(); + const [lightbox, setLightbox] = useState<{ events: PhotoEvent[]; index: number } | null>(null); + + const { data, isLoading } = useQuery({ + queryKey: ["photo-events", selectedBaby?.id], + queryFn: () => + fetch(`/api/events?babyId=${selectedBaby!.id}&limit=500`).then((r) => r.json()), + enabled: !!selectedBaby?.id, + }); + + const allEvents: PhotoEvent[] = ((data?.events ?? []) as { id: string; type: EventType; startedAt: string; notes?: string; metadata?: Record; user: { name: string } }[]) + .filter((e) => typeof e.metadata?.photo === "string") + .map((e) => ({ ...e, metadata: { photo: e.metadata!.photo as string } })); + + // Group by day + const grouped: Record = {}; + for (const ev of allEvents) { + const key = format(new Date(ev.startedAt), "yyyy-MM-dd"); + if (!grouped[key]) grouped[key] = []; + grouped[key].push(ev); + } + const days = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); + + // Flat list for lightbox navigation + const flat = days.flatMap((d) => grouped[d]); + + function openLightbox(ev: PhotoEvent) { + const index = flat.findIndex((e) => e.id === ev.id); + setLightbox({ events: flat, index }); + } + + function prev() { + setLightbox((lb) => lb && lb.index > 0 ? { ...lb, index: lb.index - 1 } : lb); + } + + function next() { + setLightbox((lb) => lb && lb.index < lb.events.length - 1 ? { ...lb, index: lb.index + 1 } : lb); + } + + const current = lightbox ? lightbox.events[lightbox.index] : null; + + return ( +
+

+ Photos{selectedBaby ? ` β€” ${selectedBaby.name}` : ""} +

+ + {isLoading && ( +
+
+
+ )} + + {!isLoading && allEvents.length === 0 && ( +
+ +

Aucune photo enregistrΓ©e

+

Ajoute une photo lors de l'enregistrement d'un Γ©vΓ©nement.

+
+ )} + +
+ {days.map((day) => ( +
+
+ {dayLabel(day)} +
+ {grouped[day].length} +
+
+ {grouped[day].map((ev) => { + const cfg = EVENT_CONFIG[ev.type]; + return ( + + ); + })} +
+
+ ))} +
+ + {/* Lightbox */} + {lightbox && current && ( +
setLightbox(null)}> + {/* Header */} +
e.stopPropagation()}> +
+

{EVENT_CONFIG[current.type].label}

+

+ {format(new Date(current.startedAt), "d MMMM yyyy Β· HH:mm", { locale: fr })} Β· {current.user.name} +

+ {current.notes &&

{current.notes}

} +
+ +
+ + {/* Image */} +
e.stopPropagation()}> +
+ +
+
+ + {/* Nav */} +
e.stopPropagation()}> + + {lightbox.index + 1} / {lightbox.events.length} + +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/stats/page.tsx b/src/app/(app)/stats/page.tsx index 00463f7..783d5a3 100644 --- a/src/app/(app)/stats/page.tsx +++ b/src/app/(app)/stats/page.tsx @@ -98,6 +98,33 @@ function computeFeedInterval(events: Event[]): string { return `${hours}h${mins.toString().padStart(2, "0")}`; } +function buildDurationTrend(events: Event[], days: number) { + return Array.from({ length: days }, (_, i) => { + const d = subDays(new Date(), days - 1 - i); + const key = format(d, "yyyy-MM-dd"); + const label = format(d, days <= 14 ? "EEE d" : "d MMM", { locale: fr }); + const dayEvents = events.filter( + (e) => + (e.type === "NAP" || e.type === "NIGHT_SLEEP") && + e.endedAt && + format(new Date(e.startedAt), "yyyy-MM-dd") === key + ); + if (dayEvents.length === 0) return { label, nap: null, night: null }; + const napEvents = dayEvents.filter((e) => e.type === "NAP"); + const nightEvents = dayEvents.filter((e) => e.type === "NIGHT_SLEEP"); + const avg = (evs: Event[]) => + evs.length === 0 + ? null + : Math.round( + evs.reduce( + (acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()) / 60000, + 0 + ) / evs.length / 6 + ) / 10; + return { label, nap: avg(napEvents), night: avg(nightEvents) }; + }); +} + function useDarkMode() { const [isDark, setIsDark] = useState(false); useEffect(() => { @@ -225,6 +252,7 @@ export default function StatsPage() { const events: Event[] = eventsData?.events ?? []; const weeklyData = buildWeeklyData(events, days); + const durationTrend = buildDurationTrend(events, Math.min(days, 30)); const feedInterval = computeFeedInterval(events); const sleepStats = computeSleepStats(events, days); const feedingStats = computeFeedingStats(events); @@ -373,6 +401,25 @@ export default function StatsPage() {
+ + {/* Duration trend */} +
+

DurΓ©e moyenne par session

+

Sieste (violet) Β· Nuit (indigo) β€” en heures

+ + + + + + [`${val}h`, name === "nap" ? "Sieste" : "Nuit"]} + contentStyle={tooltipStyle} + /> + + + + +
); diff --git a/src/app/(app)/timeline/page.tsx b/src/app/(app)/timeline/page.tsx index 0367c93..dca1b3e 100644 --- a/src/app/(app)/timeline/page.tsx +++ b/src/app/(app)/timeline/page.tsx @@ -7,7 +7,7 @@ import { EventIcon } from "@/components/event-icon"; import { useBaby } from "@/contexts/baby-context"; import { format, isToday, isYesterday } from "date-fns"; import { fr } from "date-fns/locale"; -import { Trash2, StickyNote, Pencil } from "lucide-react"; +import { Trash2, StickyNote, Pencil, CheckSquare, Square, XCircle } from "lucide-react"; import Image from "next/image"; import { EventModal } from "@/components/event-modal"; @@ -71,6 +71,8 @@ export default function TimelinePage() { const [editingEvent, setEditingEvent] = useState(null); const [filterType, setFilterType] = useState(""); const [page, setPage] = useState(0); + const [selectMode, setSelectMode] = useState(false); + const [selected, setSelected] = useState>(new Set()); const { data: eventsData, isLoading } = useQuery({ queryKey: ["events", selectedBaby?.id, filterType, page], @@ -94,6 +96,22 @@ export default function TimelinePage() { qc.invalidateQueries({ queryKey: ["events"] }); } + async function deleteSelected() { + if (selected.size === 0) return; + await Promise.all([...selected].map((id) => fetch(`/api/events/${id}`, { method: "DELETE" }))); + setSelected(new Set()); + setSelectMode(false); + qc.invalidateQueries({ queryKey: ["events"] }); + } + + function toggleSelect(id: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + return next; + }); + } + async function saveNotes(id: string, notes: string) { await fetch(`/api/events/${id}`, { method: "PATCH", @@ -112,9 +130,22 @@ export default function TimelinePage() { return (
-

- Journal{selectedBaby ? ` β€” ${selectedBaby.name}` : ""} -

+
+

+ Journal{selectedBaby ? ` β€” ${selectedBaby.name}` : ""} +

+ +
{/* Filter chips */}
+ {selectMode && ( +
+ + {selected.size} sΓ©lectionnΓ©{selected.size > 1 ? "s" : ""} + +
+ + +
+
+ )} + {isLoading && (
@@ -161,12 +217,21 @@ export default function TimelinePage() { const isExpanded = expandedId === ev.id; const summary = getEventSummary(ev); + const isSelected = selected.has(ev.id); + return ( -
+