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:
@@ -554,6 +554,13 @@ export default function DashboardPage() {
|
||||
enabled: !!selectedBaby?.id,
|
||||
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) {
|
||||
return (
|
||||
@@ -594,6 +601,63 @@ export default function DashboardPage() {
|
||||
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 (
|
||||
<div className="p-4 md:p-8 pb-24 md:pb-8">
|
||||
{/* Page header */}
|
||||
@@ -670,6 +734,42 @@ export default function DashboardPage() {
|
||||
</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 */}
|
||||
{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">
|
||||
|
||||
Reference in New Issue
Block a user