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:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user