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:
2026-06-13 16:31:47 +02:00
parent c34796452c
commit e9c8d7e97e
8 changed files with 539 additions and 12 deletions
+121
View File
@@ -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 });
}
+83
View File
@@ -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 });
}