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:
2026-06-15 22:46:22 +02:00
parent f470120add
commit f5e0f581af
9 changed files with 336 additions and 36 deletions
+91 -2
View File
@@ -12,6 +12,10 @@ interface DoctorNote {
id: string;
content: string;
date: string;
doctorName?: string | null;
reason?: string | null;
prescriptions?: string | null;
nextAppointmentAt?: string | null;
createdAt: string;
userId: string;
user: { id: string; name: string };
@@ -29,8 +33,14 @@ export default function DoctorNotesPage() {
const today = new Date().toISOString().slice(0, 10);
const [content, setContent] = useState("");
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 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[]>({
queryKey: ["doctor-notes", selectedBaby?.id],
queryFn: () =>
@@ -39,7 +49,7 @@ export default function DoctorNotesPage() {
});
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", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -55,6 +65,10 @@ export default function DoctorNotesPage() {
qc.invalidateQueries({ queryKey: ["doctor-notes", selectedBaby?.id] });
setContent("");
setDate(today);
setDoctorName("");
setReason("");
setPrescriptions("");
setNextAppointmentAt("");
setFormError(null);
},
onError: (err: Error) => {
@@ -78,7 +92,15 @@ export default function DoctorNotesPage() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
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) {
@@ -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"
/>
</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>
<label className="block text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">
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">
{note.content}
</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>