feat: doctor visit log, schedule prediction, chart PDF, natural digest
- Doctor notes: add doctorName, reason, prescriptions, nextAppointmentAt fields (schema + API + UI) - Dashboard: baby schedule prediction widget (avg nap/bedtime/first-feed over 14 days) - Growth PDF: embed weight chart as image (SVG→Canvas capture) before data table - Weekly digest: natural language push notification instead of bullet stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -73,10 +73,10 @@
|
|||||||
|
|
||||||
- [x] **Vaccination tracker** — full UI + API, overdue badges, done/upcoming sections
|
- [x] **Vaccination tracker** — full UI + API, overdue badges, done/upcoming sections
|
||||||
- [x] **Teeth tracker** — visual grid of 20 baby teeth, tap to mark appeared (in progress)
|
- [x] **Teeth tracker** — visual grid of 20 baby teeth, tap to mark appeared (in progress)
|
||||||
- [ ] **Doctor visit log** — notes, prescriptions, next appointment date
|
- [x] **Doctor visit log** — notes, prescriptions, next appointment date
|
||||||
- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
|
- [x] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns
|
||||||
- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
- [x] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table
|
||||||
- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
- [x] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push
|
||||||
- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring
|
- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring
|
||||||
- [ ] **Weight gain on-track indicator** — projected line on growth chart
|
- [ ] **Weight gain on-track indicator** — projected line on growth chart
|
||||||
- [ ] **Contraction timer** — repurpose timer infra for pre-birth use
|
- [ ] **Contraction timer** — repurpose timer infra for pre-birth use
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DoctorNote" ADD COLUMN "doctorName" TEXT,
|
||||||
|
ADD COLUMN "nextAppointmentAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "prescriptions" TEXT,
|
||||||
|
ADD COLUMN "reason" TEXT;
|
||||||
@@ -190,6 +190,10 @@ model DoctorNote {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
content String
|
content String
|
||||||
|
doctorName String?
|
||||||
|
reason String?
|
||||||
|
prescriptions String?
|
||||||
|
nextAppointmentAt DateTime?
|
||||||
date DateTime @default(now())
|
date DateTime @default(now())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -554,6 +554,13 @@ export default function DashboardPage() {
|
|||||||
enabled: !!selectedBaby?.id,
|
enabled: !!selectedBaby?.id,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
const { data: scheduleData } = useQuery({
|
||||||
|
queryKey: ["events-schedule", selectedBaby?.id],
|
||||||
|
queryFn: () =>
|
||||||
|
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=200&dateFrom=${new Date(Date.now() - 14 * 86400000).toISOString()}`).then((r) => r.json()),
|
||||||
|
enabled: !!selectedBaby?.id,
|
||||||
|
staleTime: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
if (familyLoading) {
|
if (familyLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -594,6 +601,63 @@ export default function DashboardPage() {
|
|||||||
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
|
return { avgH, avgM, lastFeedTime, nextExpected, untilNextMs };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const schedulePrediction = (() => {
|
||||||
|
const evs: Event[] = scheduleData?.events ?? [];
|
||||||
|
if (evs.length < 10) return null;
|
||||||
|
|
||||||
|
function avgHourMin(times: number[]): string | null {
|
||||||
|
if (times.length === 0) return null;
|
||||||
|
const avgMs = times.reduce((a, b) => a + b, 0) / times.length;
|
||||||
|
const totalMin = Math.round(avgMs / 60000);
|
||||||
|
const h = Math.floor(totalMin / 60) % 24;
|
||||||
|
const m = totalMin % 60;
|
||||||
|
return `${h.toString().padStart(2, "0")}h${m.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byDay: Record<string, Event[]> = {};
|
||||||
|
for (const e of evs) {
|
||||||
|
const day = e.startedAt.slice(0, 10);
|
||||||
|
if (!byDay[day]) byDay[day] = [];
|
||||||
|
byDay[day].push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const napTimes: number[] = [];
|
||||||
|
const nightTimes: number[] = [];
|
||||||
|
const firstFeedTimes: number[] = [];
|
||||||
|
|
||||||
|
for (const [, dayEvs] of Object.entries(byDay)) {
|
||||||
|
const nap = dayEvs.find((e) => e.type === "NAP");
|
||||||
|
if (nap) {
|
||||||
|
const t = new Date(nap.startedAt);
|
||||||
|
napTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const night = dayEvs.find((e) => e.type === "NIGHT_SLEEP");
|
||||||
|
if (night) {
|
||||||
|
const t = new Date(night.startedAt);
|
||||||
|
nightTimes.push((t.getHours() * 60 + t.getMinutes()) * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const feeds = dayEvs
|
||||||
|
.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE")
|
||||||
|
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
const t = new Date(feeds[0].startedAt);
|
||||||
|
const minuteOfDay = t.getHours() * 60 + t.getMinutes();
|
||||||
|
if (minuteOfDay > 240) {
|
||||||
|
firstFeedTimes.push(minuteOfDay * 60000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nap = avgHourMin(napTimes);
|
||||||
|
const night = avgHourMin(nightTimes);
|
||||||
|
const firstFeed = avgHourMin(firstFeedTimes);
|
||||||
|
|
||||||
|
if (!nap && !night && !firstFeed) return null;
|
||||||
|
return { nap, night, firstFeed };
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
@@ -670,6 +734,42 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{schedulePrediction && (
|
||||||
|
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-6">
|
||||||
|
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-3">Planning habituel</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{schedulePrediction.firstFeed && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">🍼</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">1er repas</p>
|
||||||
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.firstFeed}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedulePrediction.nap && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">😴</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">Sieste</p>
|
||||||
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.nap}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedulePrediction.night && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">🌙</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-slate-400 dark:text-slate-500">Coucher</p>
|
||||||
|
<p className="text-sm font-bold text-slate-700 dark:text-slate-200 font-mono">{schedulePrediction.night}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-slate-300 dark:text-slate-600 mt-2">Moyenne sur 14 jours</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Empty state for new users */}
|
{/* Empty state for new users */}
|
||||||
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
|
{todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && (
|
||||||
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
|
<div className="bg-indigo-50 dark:bg-indigo-950 border border-indigo-100 dark:border-indigo-900 rounded-xl p-5 mb-4 flex items-start gap-3">
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ interface DoctorNote {
|
|||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
date: string;
|
date: string;
|
||||||
|
doctorName?: string | null;
|
||||||
|
reason?: string | null;
|
||||||
|
prescriptions?: string | null;
|
||||||
|
nextAppointmentAt?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
@@ -29,8 +33,14 @@ export default function DoctorNotesPage() {
|
|||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const [content, setContent] = useState("");
|
const [content, setContent] = useState("");
|
||||||
const [date, setDate] = useState(today);
|
const [date, setDate] = useState(today);
|
||||||
|
const [doctorName, setDoctorName] = useState("");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [prescriptions, setPrescriptions] = useState("");
|
||||||
|
const [nextAppointmentAt, setNextAppointmentAt] = useState("");
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const inputCls = "w-full px-3 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100";
|
||||||
|
|
||||||
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
|
const { data: notes = [], isLoading: notesLoading } = useQuery<DoctorNote[]>({
|
||||||
queryKey: ["doctor-notes", selectedBaby?.id],
|
queryKey: ["doctor-notes", selectedBaby?.id],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -39,7 +49,7 @@ export default function DoctorNotesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (body: { babyId: string; content: string; date: string }) =>
|
mutationFn: (body: { babyId: string; content: string; date: string; doctorName?: string; reason?: string; prescriptions?: string; nextAppointmentAt?: string }) =>
|
||||||
fetch("/api/doctor-notes", {
|
fetch("/api/doctor-notes", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -55,6 +65,10 @@ export default function DoctorNotesPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
|
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
|
||||||
setContent("");
|
setContent("");
|
||||||
setDate(today);
|
setDate(today);
|
||||||
|
setDoctorName("");
|
||||||
|
setReason("");
|
||||||
|
setPrescriptions("");
|
||||||
|
setNextAppointmentAt("");
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
},
|
},
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
@@ -78,7 +92,15 @@ export default function DoctorNotesPage() {
|
|||||||
function handleSubmit(e: React.FormEvent) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedBaby?.id || !content.trim() || !date) return;
|
if (!selectedBaby?.id || !content.trim() || !date) return;
|
||||||
createMutation.mutate({ babyId: selectedBaby.id, content: content.trim(), date });
|
createMutation.mutate({
|
||||||
|
babyId: selectedBaby.id,
|
||||||
|
content: content.trim(),
|
||||||
|
date,
|
||||||
|
doctorName: doctorName || undefined,
|
||||||
|
reason: reason || undefined,
|
||||||
|
prescriptions: prescriptions || undefined,
|
||||||
|
nextAppointmentAt: nextAppointmentAt || undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (familyLoading) {
|
if (familyLoading) {
|
||||||
@@ -135,6 +157,53 @@ export default function DoctorNotesPage() {
|
|||||||
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
|
className="w-full rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-900 dark:text-slate-100 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 dark:focus:ring-emerald-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||||
|
Médecin <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={doctorName}
|
||||||
|
onChange={(e) => setDoctorName(e.target.value)}
|
||||||
|
placeholder="Dr. Dupont"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||||
|
Motif <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Visite de routine, fièvre..."
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||||
|
Prescriptions <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={prescriptions}
|
||||||
|
onChange={(e) => setPrescriptions(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Doliprane 2.4% si fièvre..."
|
||||||
|
className={inputCls + " resize-none"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">
|
||||||
|
Prochain rendez-vous <span className="text-slate-400 font-normal">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={nextAppointmentAt}
|
||||||
|
onChange={(e) => setNextAppointmentAt(e.target.value)}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
|
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
|
||||||
Observation
|
Observation
|
||||||
@@ -214,6 +283,26 @@ export default function DoctorNotesPage() {
|
|||||||
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
|
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap leading-relaxed">
|
||||||
{note.content}
|
{note.content}
|
||||||
</p>
|
</p>
|
||||||
|
{note.doctorName && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
<span className="font-medium">Médecin :</span> {note.doctorName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{note.reason && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
<span className="font-medium">Motif :</span> {note.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{note.prescriptions && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 whitespace-pre-line">
|
||||||
|
<span className="font-medium">Prescriptions :</span> {note.prescriptions}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{note.nextAppointmentAt && (
|
||||||
|
<p className="text-xs text-indigo-600 dark:text-indigo-400 mt-1 font-medium">
|
||||||
|
📅 Prochain RDV : {format(new Date(note.nextAppointmentAt), "d MMMM yyyy", { locale: fr })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
@@ -168,6 +168,7 @@ export default function GrowthPage() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}
|
}
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
|
const [timeRange, setTimeRange] = useState<TimeRange>("tout");
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
const [editForm, setEditForm] = useState({ date: "", weight: "", height: "", headCirc: "", notes: "" });
|
||||||
@@ -241,7 +242,38 @@ export default function GrowthPage() {
|
|||||||
qc.invalidateQueries({ queryKey: ["growth"] });
|
qc.invalidateQueries({ queryKey: ["growth"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPDF() {
|
async function captureChartAsImage(): Promise<string | null> {
|
||||||
|
if (!chartRef.current) return null;
|
||||||
|
const svg = chartRef.current.querySelector("svg");
|
||||||
|
if (!svg) return null;
|
||||||
|
try {
|
||||||
|
const svgData = new XMLSerializer().serializeToString(svg);
|
||||||
|
const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(svgBlob);
|
||||||
|
return await new Promise<string>((resolve) => {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const scale = 2;
|
||||||
|
canvas.width = svg.clientWidth * scale;
|
||||||
|
canvas.height = svg.clientHeight * scale;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.scale(scale, scale);
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
resolve(canvas.toDataURL("image/png"));
|
||||||
|
};
|
||||||
|
img.onerror = () => { URL.revokeObjectURL(url); resolve(null as unknown as string); };
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportPDF() {
|
||||||
function getPercentileBand(ageWeeks: number, weightKg: number): string {
|
function getPercentileBand(ageWeeks: number, weightKg: number): string {
|
||||||
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
|
const values = PERCENTILE_LABELS.map((_, i) => interpolatePercentile(ageWeeks, i) ?? 0);
|
||||||
if (weightKg < values[0]) return "< P3";
|
if (weightKg < values[0]) return "< P3";
|
||||||
@@ -251,6 +283,7 @@ export default function GrowthPage() {
|
|||||||
return "> P97";
|
return "> P97";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartImg = await captureChartAsImage();
|
||||||
import("jspdf").then(({ default: jsPDF }) => {
|
import("jspdf").then(({ default: jsPDF }) => {
|
||||||
const doc = new jsPDF();
|
const doc = new jsPDF();
|
||||||
const pageW = doc.internal.pageSize.getWidth();
|
const pageW = doc.internal.pageSize.getWidth();
|
||||||
@@ -267,7 +300,18 @@ export default function GrowthPage() {
|
|||||||
// Table header
|
// Table header
|
||||||
const COL = [14, 55, 95, 130, 155];
|
const COL = [14, 55, 95, 130, 155];
|
||||||
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
|
const headers = ["Date", "Poids (kg)", "Taille (cm)", "PC (cm)", "Notes"];
|
||||||
let y = 46;
|
let y: number;
|
||||||
|
if (chartImg) {
|
||||||
|
const imgWidth = pageW - 28;
|
||||||
|
const imgHeight = 70;
|
||||||
|
doc.setFontSize(11);
|
||||||
|
doc.setTextColor(30, 41, 59);
|
||||||
|
doc.text("Courbe de poids", 14, 42);
|
||||||
|
doc.addImage(chartImg, "PNG", 14, 45, imgWidth, imgHeight);
|
||||||
|
y = 45 + imgHeight + 10;
|
||||||
|
} else {
|
||||||
|
y = 46;
|
||||||
|
}
|
||||||
|
|
||||||
doc.setFillColor(238, 242, 255);
|
doc.setFillColor(238, 242, 255);
|
||||||
doc.rect(14, y - 5, pageW - 28, 9, "F");
|
doc.rect(14, y - 5, pageW - 28, 9, "F");
|
||||||
@@ -419,7 +463,7 @@ export default function GrowthPage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Weight + WHO */}
|
{/* Weight + WHO */}
|
||||||
{weightData.length > 0 && (
|
{weightData.length > 0 && (
|
||||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
<div ref={chartRef} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
|
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200">Poids (kg)</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
|
<div className="flex items-center gap-3 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
|||||||
@@ -47,11 +47,9 @@ export async function POST(req: Request) {
|
|||||||
for (const family of families) {
|
for (const family of families) {
|
||||||
if (family.users.length === 0) continue;
|
if (family.users.length === 0) continue;
|
||||||
|
|
||||||
const lines: string[] = [`📊 Résumé semaine — ${family.name}`, ""];
|
const lines: string[] = [];
|
||||||
|
|
||||||
for (const baby of family.babies) {
|
for (const baby of family.babies) {
|
||||||
lines.push(`👶 ${baby.name}`);
|
|
||||||
|
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
where: { babyId: baby.id, startedAt: { gte: since } },
|
where: { babyId: baby.id, startedAt: { gte: since } },
|
||||||
orderBy: { startedAt: "asc" },
|
orderBy: { startedAt: "asc" },
|
||||||
@@ -59,36 +57,50 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
// Feedings
|
// Feedings
|
||||||
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||||
if (feeds.length > 0) {
|
|
||||||
const perDay = (feeds.length / 7).toFixed(1);
|
|
||||||
lines.push(`🍼 Repas: ${feeds.length} au total (${perDay}/j)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sleep
|
// Sleep
|
||||||
const sleepEvents = events.filter(
|
const sleepEvents = events.filter(
|
||||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||||
);
|
);
|
||||||
if (sleepEvents.length > 0) {
|
|
||||||
const totalSleepMs = sleepEvents.reduce((acc, e) => {
|
const totalSleepMs = sleepEvents.reduce((acc, e) => {
|
||||||
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
|
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
|
||||||
}, 0);
|
}, 0);
|
||||||
const avgPerDay = totalSleepMs / 7;
|
|
||||||
lines.push(`😴 Sommeil: ${fmtMs(totalSleepMs)} total, ~${fmtMs(avgPerDay)}/j`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Diapers
|
// Diapers
|
||||||
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
||||||
if (diapers.length > 0) {
|
|
||||||
lines.push(`👶 Couches: ${diapers.length} (${(diapers.length / 7).toFixed(1)}/j)`);
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
const perDay = Math.round(feeds.length / 7);
|
||||||
|
parts.push(`${feeds.length} repas (${perDay}/j)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sleepEvents.length > 0) {
|
||||||
|
const avgPerDay = totalSleepMs / 7;
|
||||||
|
parts.push(`${fmtMs(avgPerDay)}/j de sommeil`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diapers.length > 0) {
|
||||||
|
const perDay = Math.round(diapers.length / 7);
|
||||||
|
parts.push(`${diapers.length} couches (${perDay}/j)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let babyLine = `👶 ${baby.name} cette semaine : `;
|
||||||
|
if (parts.length > 0) {
|
||||||
|
babyLine += parts.join(", ") + ".";
|
||||||
|
} else {
|
||||||
|
babyLine += "peu d'événements enregistrés.";
|
||||||
|
}
|
||||||
|
lines.push(babyLine);
|
||||||
|
|
||||||
// Weight (latest growth log in the period)
|
// Weight (latest growth log in the period)
|
||||||
const latestWeight = await prisma.growthLog.findFirst({
|
const latestWeight = await prisma.growthLog.findFirst({
|
||||||
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
|
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
|
||||||
orderBy: { date: "desc" },
|
orderBy: { date: "desc" },
|
||||||
});
|
});
|
||||||
if (latestWeight?.weight) {
|
if (latestWeight?.weight) {
|
||||||
lines.push(`⚖️ Poids: ${latestWeight.weight} kg`);
|
lines.push(`⚖️ Poids : ${(latestWeight.weight / 1000).toFixed(3)} kg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
@@ -98,7 +110,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
family.users.map((u) =>
|
family.users.map((u) =>
|
||||||
sendPushover(u.pushoverUserKey!, message, "Grow — Résumé hebdomadaire")
|
sendPushover(u.pushoverUserKey!, message, `📊 Résumé hebdo — ${family.name}`)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
sent += family.users.length;
|
sent += family.users.length;
|
||||||
|
|||||||
@@ -29,3 +29,45 @@ export async function DELETE(
|
|||||||
await prisma.doctorNote.delete({ where: { id } });
|
await prisma.doctorNote.delete({ where: { id } });
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||||
|
|
||||||
|
const userRole = (session.user as { role?: string }).role;
|
||||||
|
if (userRole !== "DOCTOR") {
|
||||||
|
return NextResponse.json({ error: "Réservé aux médecins" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const note = await prisma.doctorNote.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return NextResponse.json({ error: "Observation introuvable" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Vous ne pouvez modifier que vos propres observations" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const updated = await prisma.doctorNote.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(body.content !== undefined && { content: body.content }),
|
||||||
|
...(body.date !== undefined && { date: new Date(body.date) }),
|
||||||
|
doctorName: body.doctorName || null,
|
||||||
|
reason: body.reason || null,
|
||||||
|
prescriptions: body.prescriptions || null,
|
||||||
|
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
|
||||||
|
},
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export async function POST(req: Request) {
|
|||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
content,
|
content,
|
||||||
date: noteDate,
|
date: noteDate,
|
||||||
|
doctorName: body.doctorName || null,
|
||||||
|
reason: body.reason || null,
|
||||||
|
prescriptions: body.prescriptions || null,
|
||||||
|
nextAppointmentAt: body.nextAppointmentAt ? new Date(body.nextAppointmentAt) : null,
|
||||||
},
|
},
|
||||||
include: { user: { select: { id: true, name: true } } },
|
include: { user: { select: { id: true, name: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user