feat: feeding heatmap, diaper streak, quick note, handoff summary, weight percentile alert, growth curve labels

- stats: 7×24 feed density heatmap (Mon–Sun × 24h, indigo intensity, dark mode)
- stats: diaper streak card — current consecutive days ≥4 couches + best streak for period
- dashboard: QuickNoteWidget — inline journal note (sky card) without leaving dashboard
- dashboard: HandoffWidget — localStorage "since last visit" digest (feeds/diapers/sleep) shown after 2h gap
- growth: replace S0/S1/S2 x-axis with human labels (Naiss., 1sem, 2sem, 1m, 2m … 1 an)
- growth: tooltip shows age in full words (e.g. "3 mois 1 sem.")
- cron: POST /api/cron/weight-percentile-alert — alerts when baby weight exits configurable WHO band (weight_percentile_min / weight_percentile_max SystemConfig keys, deduped per measurement)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 17:05:04 +02:00
parent b417eb648c
commit e27f42942c
5 changed files with 419 additions and 15 deletions
@@ -0,0 +1,117 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendPushover } from "@/lib/push-notify";
import { getAgeInWeeks, interpolatePercentile, PERCENTILE_LABELS } from "@/lib/who-percentiles";
// Call daily with:
// POST /api/cron/weight-percentile-alert
// 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 || "";
}
async function getPercentileConfig(): Promise<{ minLabel: string; maxLabel: string }> {
const [minRow, maxRow] = await Promise.all([
prisma.systemConfig.findUnique({ where: { key: "weight_percentile_min" } }),
prisma.systemConfig.findUnique({ where: { key: "weight_percentile_max" } }),
]);
return {
minLabel: minRow?.value ?? "P3",
maxLabel: maxRow?.value ?? "P97",
};
}
function labelToIndex(label: string): number {
const idx = PERCENTILE_LABELS.indexOf(label);
return idx === -1 ? 0 : idx;
}
function getPercentileBand(ageWeeks: number, weight: number): string {
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
if (weight < values[0]) return "< P3";
for (let i = 0; i < values.length - 1; i++) {
if (weight >= values[i] && weight < values[i + 1]) {
return `${PERCENTILE_LABELS[i]}${PERCENTILE_LABELS[i + 1]}`;
}
}
return "> P97";
}
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 { minLabel, maxLabel } = await getPercentileConfig();
const minIdx = labelToIndex(minLabel);
const maxIdx = labelToIndex(maxLabel);
const families = await prisma.family.findMany({
include: {
babies: {
include: {
growthLogs: {
where: { weight: { not: null } },
orderBy: { date: "desc" },
take: 1,
},
},
},
users: {
where: { pushoverUserKey: { not: null } },
select: { pushoverUserKey: true },
},
},
});
const alerts: { babyName: string; band: string; weight: number }[] = [];
for (const family of families) {
if (family.users.length === 0) continue;
for (const baby of family.babies) {
const log = baby.growthLogs[0];
if (!log?.weight) continue;
const alertSentKey = `weight_percentile_alert_last_${baby.id}`;
const lastSentRow = await prisma.systemConfig.findUnique({ where: { key: alertSentKey } });
if (lastSentRow?.value === log.id) continue;
const ageWeeks = getAgeInWeeks(new Date(baby.birthDate), new Date(log.date));
if (ageWeeks < 0 || ageWeeks > 52) continue;
const minThreshold = interpolatePercentile(ageWeeks, minIdx);
const maxThreshold = interpolatePercentile(ageWeeks, maxIdx);
if (minThreshold === null || maxThreshold === null) continue;
const outsideBounds = log.weight < minThreshold || log.weight > maxThreshold;
if (!outsideBounds) continue;
const band = getPercentileBand(ageWeeks, log.weight);
const direction = log.weight < minThreshold ? `en dessous de ${minLabel}` : `au dessus de ${maxLabel}`;
const message = `Le poids de ${baby.name} (${log.weight} kg) est ${direction} — percentile ${band}. Consultez la courbe de croissance.`;
for (const user of family.users) {
if (user.pushoverUserKey) {
await sendPushover(user.pushoverUserKey, message, `⚖️ Alerte poids — ${baby.name}`);
}
}
await prisma.systemConfig.upsert({
where: { key: alertSentKey },
update: { value: log.id },
create: { key: alertSentKey, value: log.id },
});
alerts.push({ babyName: baby.name, band, weight: log.weight });
}
}
return NextResponse.json({ ok: true, alertsSent: alerts.length, alerts, minLabel, maxLabel });
}