feat: daily digest, photo timeline, milk expiry cron, batch delete, duration chart
- /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 <noreply@anthropic.com>
This commit is contained in:
+5
-5
@@ -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
|
||||
|
||||
@@ -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<string, unknown>; 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<string, PhotoEvent[]> = {};
|
||||
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 (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">
|
||||
Photos{selectedBaby ? ` — ${selectedBaby.name}` : ""}
|
||||
</h1>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && allEvents.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500 gap-3">
|
||||
<Camera className="w-10 h-10 opacity-40" />
|
||||
<p className="text-sm">Aucune photo enregistrée</p>
|
||||
<p className="text-xs text-center max-w-xs">Ajoute une photo lors de l'enregistrement d'un événement.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{days.map((day) => (
|
||||
<div key={day}>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
|
||||
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5">
|
||||
{grouped[day].map((ev) => {
|
||||
const cfg = EVENT_CONFIG[ev.type];
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => openLightbox(ev)}
|
||||
className="relative aspect-square rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-800 group"
|
||||
>
|
||||
<Image
|
||||
src={ev.metadata.photo}
|
||||
alt={cfg.label}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/50 to-transparent px-1.5 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-[10px] text-white font-medium truncate">{cfg.label}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && current && (
|
||||
<div className="fixed inset-0 z-50 bg-black/90 flex flex-col" onClick={() => setLightbox(null)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{EVENT_CONFIG[current.type].label}</p>
|
||||
<p className="text-white/60 text-xs">
|
||||
{format(new Date(current.startedAt), "d MMMM yyyy · HH:mm", { locale: fr })} · {current.user.name}
|
||||
</p>
|
||||
{current.notes && <p className="text-white/80 text-xs mt-0.5 italic">{current.notes}</p>}
|
||||
</div>
|
||||
<button onClick={() => setLightbox(null)} className="w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition">
|
||||
<X className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center px-4 relative min-h-0" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="relative w-full h-full max-w-lg">
|
||||
<Image src={current.metadata.photo} alt="" fill className="object-contain" unoptimized />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between px-4 py-4 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={prev}
|
||||
disabled={lightbox.index === 0}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
<span className="text-white/60 text-xs">{lightbox.index + 1} / {lightbox.events.length}</span>
|
||||
<button
|
||||
onClick={next}
|
||||
disabled={lightbox.index === lightbox.events.length - 1}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Duration trend */}
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Durée moyenne par session</h3>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Sieste (violet) · Nuit (indigo) — en heures</p>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={durationTrend} margin={{ top: 0, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={isDark ? "#334155" : "#f1f5f9"} />
|
||||
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
formatter={(val, name) => [`${val}h`, name === "nap" ? "Sieste" : "Nuit"]}
|
||||
contentStyle={tooltipStyle}
|
||||
/>
|
||||
<Line dataKey="nap" stroke="#8b5cf6" strokeWidth={2} dot={false} connectNulls />
|
||||
<Line dataKey="night" stroke="#4f46e5" strokeWidth={2} dot={false} connectNulls />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<Event | null>(null);
|
||||
const [filterType, setFilterType] = useState<EventType | "">("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(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 (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Journal{selectedBaby ? ` — ${selectedBaby.name}` : ""}
|
||||
</h1>
|
||||
<button
|
||||
onClick={() => { setSelectMode((v) => !v); setSelected(new Set()); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition ${
|
||||
selectMode
|
||||
? "bg-indigo-600 text-white border-indigo-600"
|
||||
: "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600"
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
Sélectionner
|
||||
</button>
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
<button
|
||||
@@ -140,6 +171,31 @@ export default function TimelinePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectMode && (
|
||||
<div className="flex items-center justify-between mb-4 px-4 py-2.5 bg-indigo-50 dark:bg-indigo-950/50 border border-indigo-200 dark:border-indigo-800 rounded-xl">
|
||||
<span className="text-sm text-indigo-700 dark:text-indigo-300 font-medium">
|
||||
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => { setSelectMode(false); setSelected(new Set()); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800"
|
||||
>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { if (confirm(`Supprimer ${selected.size} événement${selected.size > 1 ? "s" : ""} ?`)) deleteSelected(); }}
|
||||
disabled={selected.size === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-red-600 text-white font-medium disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
Supprimer ({selected.size})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-16">
|
||||
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
|
||||
@@ -161,12 +217,21 @@ export default function TimelinePage() {
|
||||
const isExpanded = expandedId === ev.id;
|
||||
const summary = getEventSummary(ev);
|
||||
|
||||
const isSelected = selected.has(ev.id);
|
||||
|
||||
return (
|
||||
<div key={ev.id}>
|
||||
<div key={ev.id} className={isSelected ? "bg-indigo-50 dark:bg-indigo-950/30" : ""}>
|
||||
<button
|
||||
className="w-full text-left flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
|
||||
onClick={() => setExpandedId(isExpanded ? null : ev.id)}
|
||||
onClick={() => selectMode ? toggleSelect(ev.id) : setExpandedId(isExpanded ? null : ev.id)}
|
||||
>
|
||||
{selectMode && (
|
||||
<div className="flex-shrink-0">
|
||||
{isSelected
|
||||
? <CheckSquare className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
|
||||
: <Square className="w-4 h-4 text-slate-300 dark:text-slate-600" />}
|
||||
</div>
|
||||
)}
|
||||
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPushover } from "@/lib/push-notify";
|
||||
|
||||
// Call daily (e.g. 07:00) with:
|
||||
// POST /api/cron/daily-digest
|
||||
// Authorization: Bearer <CRON_SECRET>
|
||||
|
||||
async function getCronSecret(): Promise<string> {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
|
||||
return row?.value || process.env.CRON_SECRET || "";
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
function fmtTime(date: Date): string {
|
||||
return date.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Paris" });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = await getCronSecret();
|
||||
if (secret) {
|
||||
const authHeader = req.headers.get("authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - 24 * 3600 * 1000);
|
||||
|
||||
const families = await prisma.family.findMany({
|
||||
include: {
|
||||
babies: true,
|
||||
users: { where: { pushoverUserKey: { not: null } }, select: { pushoverUserKey: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
|
||||
for (const family of families) {
|
||||
if (family.users.length === 0) continue;
|
||||
|
||||
for (const baby of family.babies) {
|
||||
const events = await prisma.event.findMany({
|
||||
where: { babyId: baby.id, startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "desc" },
|
||||
});
|
||||
|
||||
const lines: string[] = [`☀️ Résumé — ${baby.name}`, ""];
|
||||
|
||||
// Last feed
|
||||
const lastFeed = events.find((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||
if (lastFeed) {
|
||||
const label = lastFeed.type === "BREASTFEED" ? "allaitement" : "biberon";
|
||||
const ago = fmtMs(Date.now() - new Date(lastFeed.startedAt).getTime());
|
||||
lines.push(`🍼 Dernier repas : ${label} il y a ${ago} (${fmtTime(new Date(lastFeed.startedAt))})`);
|
||||
} else {
|
||||
lines.push("🍼 Aucun repas dans les 24h");
|
||||
}
|
||||
|
||||
// Last diaper
|
||||
const lastDiaper = events.find((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
||||
if (lastDiaper) {
|
||||
const ago = fmtMs(Date.now() - new Date(lastDiaper.startedAt).getTime());
|
||||
lines.push(`🧷 Dernière couche : il y a ${ago}`);
|
||||
} else {
|
||||
lines.push("🧷 Aucune couche dans les 24h");
|
||||
}
|
||||
|
||||
// Sleep total
|
||||
const sleepEvents = events.filter(
|
||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||
);
|
||||
const sleepMs = sleepEvents.reduce(
|
||||
(acc, e) => acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime()),
|
||||
0
|
||||
);
|
||||
if (sleepMs > 0) {
|
||||
lines.push(`😴 Sommeil total : ${fmtMs(sleepMs)}`);
|
||||
}
|
||||
|
||||
// Next medication due
|
||||
const medEvents = events.filter((e) => e.type === "MEDICATION");
|
||||
if (medEvents.length > 0) {
|
||||
const byProfile: Record<string, { lastAt: Date; intervalHours: number; name: string }> = {};
|
||||
for (const ev of medEvents) {
|
||||
const meta = (ev.metadata ?? {}) as Record<string, unknown>;
|
||||
const profileId = meta.profileId as string | undefined;
|
||||
const name = (meta.name as string) ?? "Médicament";
|
||||
const intervalHours = (meta.intervalHours as number) ?? 6;
|
||||
if (profileId && (!byProfile[profileId] || new Date(ev.startedAt) > byProfile[profileId].lastAt)) {
|
||||
byProfile[profileId] = { lastAt: new Date(ev.startedAt), intervalHours, name };
|
||||
}
|
||||
}
|
||||
for (const { lastAt, intervalHours, name } of Object.values(byProfile)) {
|
||||
const nextAt = new Date(lastAt.getTime() + intervalHours * 3600000);
|
||||
if (nextAt > new Date()) {
|
||||
const inMs = nextAt.getTime() - Date.now();
|
||||
lines.push(`💊 ${name} : prochain dans ${fmtMs(inMs)} (${fmtTime(nextAt)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = lines.join("\n");
|
||||
|
||||
for (const user of family.users) {
|
||||
if (user.pushoverUserKey) {
|
||||
await sendPushover(user.pushoverUserKey, message, `☀️ Digest — ${baby.name}`);
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sent });
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPushover } from "@/lib/push-notify";
|
||||
|
||||
// Call daily with:
|
||||
// POST /api/cron/milk-expiry
|
||||
// Authorization: Bearer <CRON_SECRET>
|
||||
|
||||
async function getCronSecret(): Promise<string> {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
|
||||
return row?.value || process.env.CRON_SECRET || "";
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = await getCronSecret();
|
||||
if (secret) {
|
||||
const authHeader = req.headers.get("authorization") ?? "";
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const windowEnd = new Date(Date.now() + 24 * 3600 * 1000);
|
||||
|
||||
const expiringLots = await prisma.milkStock.findMany({
|
||||
where: {
|
||||
used: false,
|
||||
expiresAt: { lte: windowEnd },
|
||||
},
|
||||
include: {
|
||||
baby: {
|
||||
include: {
|
||||
family: {
|
||||
include: {
|
||||
users: {
|
||||
where: { pushoverUserKey: { not: null } },
|
||||
select: { pushoverUserKey: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { expiresAt: "asc" },
|
||||
});
|
||||
|
||||
if (expiringLots.length === 0) {
|
||||
return NextResponse.json({ ok: true, sent: 0 });
|
||||
}
|
||||
|
||||
// Group by family
|
||||
const byFamily = new Map<string, { users: { pushoverUserKey: string | null }[]; lines: string[]; babyName: string }>();
|
||||
|
||||
for (const lot of expiringLots) {
|
||||
const familyId = lot.baby.family.id;
|
||||
if (!byFamily.has(familyId)) {
|
||||
byFamily.set(familyId, { users: lot.baby.family.users, lines: [], babyName: lot.baby.name });
|
||||
}
|
||||
const entry = byFamily.get(familyId)!;
|
||||
const expiresIn = lot.expiresAt.getTime() - Date.now();
|
||||
const hoursLeft = Math.max(0, Math.floor(expiresIn / 3600000));
|
||||
const loc = lot.location === "fridge" ? "frigo" : lot.location === "freezer" ? "congélateur" : "ambiant";
|
||||
const alreadyExpired = expiresIn <= 0;
|
||||
entry.lines.push(
|
||||
alreadyExpired
|
||||
? `⚠️ ${lot.volume} mL (${loc}) — expiré`
|
||||
: `🥛 ${lot.volume} mL (${loc}) — expire dans ${hoursLeft}h`
|
||||
);
|
||||
}
|
||||
|
||||
let sent = 0;
|
||||
for (const { users, lines, babyName } of byFamily.values()) {
|
||||
const message = [`🥛 Stock lait — ${babyName}`, "", ...lines].join("\n");
|
||||
for (const user of users) {
|
||||
if (user.pushoverUserKey) {
|
||||
await sendPushover(user.pushoverUserKey, message, `🥛 Alerte stock lait`);
|
||||
sent++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sent, lots: expiringLots.length });
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
CalendarDays,
|
||||
Milk,
|
||||
Pill,
|
||||
ImageIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
const LINKS = [
|
||||
@@ -36,6 +37,7 @@ const LINKS = [
|
||||
{ href: "/stats", label: "Statistiques", Icon: BarChart3 },
|
||||
{ href: "/growth", label: "Courbes", Icon: TrendingUp },
|
||||
{ href: "/milk", label: "Stock lait", Icon: Milk },
|
||||
{ href: "/photos", label: "Photos", Icon: ImageIcon },
|
||||
{ href: "/milestones", label: "Étapes", Icon: Star },
|
||||
{ href: "/notes", label: "Notes", Icon: NotebookPen },
|
||||
{ href: "/doctor-notes", label: "Médecin", Icon: Stethoscope },
|
||||
@@ -49,7 +51,7 @@ const LINKS = [
|
||||
const DRAWER_GROUPS: { label: string; hrefs: string[] }[] = [
|
||||
{ label: "Suivi", hrefs: ["/calendar", "/stats", "/milk"] },
|
||||
{ label: "Santé", hrefs: ["/medications", "/reminders", "/vaccinations", "/doctor-notes"] },
|
||||
{ label: "Vie du bébé", hrefs: ["/milestones", "/notes", "/growth"] },
|
||||
{ label: "Vie du bébé", hrefs: ["/photos", "/milestones", "/notes", "/growth"] },
|
||||
];
|
||||
|
||||
const PRIMARY_HREFS = ["/dashboard", "/timeline", "/growth", "/settings"];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# ─── Database ───────────────────────────────────────────────────────────────
|
||||
# PostgreSQL connection string.
|
||||
# Local: docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=postgres postgres
|
||||
# Cloud: Railway, Supabase, Neon, etc.
|
||||
DATABASE_URL="postgresql://grow:0bweqoQtUVBHk%2Fntb3u93ynHAOkeJ1jEiPUjq68Fov4%3D@db:5432/grow"
|
||||
POSTGRES_PASSWORD="0bweqoQtUVBHk/ntb3u93ynHAOkeJ1jEiPUjq68Fov4="
|
||||
|
||||
# ─── Auth ───────────────────────────────────────────────────────────────────
|
||||
# Generate with: openssl rand -base64 32
|
||||
AUTH_SECRET="0Ku8DuYGiyzQRgin/o/8QTFV8P+eMLbxe3Dqzni6nPo="
|
||||
NEXTAUTH_URL="https://grow.arnaudne.fr"
|
||||
|
||||
# ─── Superadmin ─────────────────────────────────────────────────────────────
|
||||
# Email address of the superadmin user.
|
||||
# That user gets access to /admin (service config panel).
|
||||
# If unset, no one can access /admin.
|
||||
SUPERADMIN_EMAIL="arnaud.nelissen@icloud.com"
|
||||
|
||||
# ─── Email — Mailgun ────────────────────────────────────────────────────────
|
||||
# Used for forgot-password emails.
|
||||
# Set email_provider=mailgun in /admin, or set these env vars as fallback.
|
||||
# MAILGUN_API_KEY="key-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
# MAILGUN_DOMAIN="mg.yourdomain.com"
|
||||
# MAILGUN_FROM="Grow <noreply@mg.yourdomain.com>"
|
||||
|
||||
# ─── Email — SMTP ───────────────────────────────────────────────────────────
|
||||
# Alternative to Mailgun. Set email_provider=smtp in /admin.
|
||||
# Works with any SMTP relay (Gmail App Password, Brevo, Postmark SMTP, etc.)
|
||||
SMTP_HOST="smtp.eu.mailgun.org"
|
||||
SMTP_PORT="587"
|
||||
SMTP_USER="grow@arnaudne.fr"
|
||||
SMTP_PASS="b7a0fb95175b812d54d6eafb4033e609-d2d7ea9a-051a3ccb"
|
||||
SMTP_FROM="Grow <grow@arnaudne.fr>"
|
||||
|
||||
# ─── Push notifications — Pushover ──────────────────────────────────────────
|
||||
# App token from https://pushover.net/apps/build
|
||||
# Each parent enters their own User Key in Settings → Notifications.
|
||||
PUSHOVER_APP_TOKEN="at7n5cs8x84an6u7jorwpmqok2dhed"
|
||||
Reference in New Issue
Block a user