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:
@@ -9,7 +9,8 @@ import { useBaby } from "@/contexts/baby-context";
|
||||
import { checkFeedAlert, getThresholds } from "@/lib/notifications";
|
||||
import { format, isToday } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2, FileText } from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
|
||||
{
|
||||
@@ -69,6 +70,160 @@ function getEventSummary(event: Event): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function QuickNoteWidget({ babyId }: { babyId: string }) {
|
||||
const qc = useQueryClient();
|
||||
const todayStr = format(new Date(), "yyyy-MM-dd");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data: notes = [] } = useQuery<Array<{ id: string; content: string; user: { name: string } }>>({
|
||||
queryKey: ["journal-today", babyId, todayStr],
|
||||
queryFn: () => fetch(`/api/journal?babyId=${babyId}&date=${todayStr}`).then((r) => r.json()),
|
||||
});
|
||||
const todayNote = notes[0] ?? null;
|
||||
|
||||
function startEdit() {
|
||||
setDraft(todayNote?.content ?? "");
|
||||
setEditing(true);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!draft.trim()) return;
|
||||
setSaving(true);
|
||||
if (todayNote) {
|
||||
await fetch(`/api/journal/${todayNote.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: draft }),
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/journal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ babyId, date: todayStr, content: draft }),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: ["journal-today", babyId] });
|
||||
setEditing(false);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-sky-50 dark:bg-sky-950/40 border border-sky-200 dark:border-sky-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-600 dark:text-sky-400" />
|
||||
<span className="text-xs font-semibold text-sky-700 dark:text-sky-400 uppercase tracking-wide">Note du jour</span>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button onClick={startEdit}
|
||||
className="flex items-center gap-1 text-xs text-sky-600 dark:text-sky-400 hover:text-sky-800 dark:hover:text-sky-200 transition">
|
||||
<Pencil className="w-3 h-3" />
|
||||
{todayNote ? "Modifier" : "Ajouter"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-lg border border-sky-300 dark:border-sky-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-sky-400 resize-none"
|
||||
placeholder="Note rapide pour aujourd'hui..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-sky-500 hover:bg-sky-600 text-white text-xs rounded-lg font-medium transition disabled:opacity-50">
|
||||
<Check className="w-3 h-3" />
|
||||
{saving ? "..." : "Enregistrer"}
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 border border-sky-300 dark:border-sky-700 text-sky-700 dark:text-sky-400 text-xs rounded-lg transition hover:bg-sky-100 dark:hover:bg-sky-950">
|
||||
<X className="w-3 h-3" />
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : todayNote ? (
|
||||
<p className="text-sm text-sky-900 dark:text-sky-100 whitespace-pre-wrap">{todayNote.content}</p>
|
||||
) : (
|
||||
<p className="text-xs text-sky-500 dark:text-sky-600 italic">Aucune note aujourd'hui.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HandoffWidget({ events, userId }: { events: Event[]; userId: string }) {
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
const [sinceTime, setSinceTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const key = `handoff_seen_${userId}`;
|
||||
const stored = localStorage.getItem(key);
|
||||
const now = new Date();
|
||||
if (stored) {
|
||||
const last = new Date(stored);
|
||||
if (now.getTime() - last.getTime() > 2 * 3600 * 1000) {
|
||||
setSinceTime(last);
|
||||
setDismissed(false);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(key, now.toISOString());
|
||||
}, [userId]);
|
||||
|
||||
if (dismissed || !sinceTime) return null;
|
||||
|
||||
const sinceEvents = events.filter((e) => new Date(e.startedAt) >= sinceTime!);
|
||||
if (sinceEvents.length === 0) return null;
|
||||
|
||||
const feeds = sinceEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
|
||||
const diapers = sinceEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
|
||||
const sleepMs = sinceEvents
|
||||
.filter((e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt)
|
||||
.reduce((acc, e) => acc + new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime(), 0);
|
||||
const sleepH = Math.round((sleepMs / 3600000) * 10) / 10;
|
||||
const elapsedH = Math.round((new Date().getTime() - sinceTime.getTime()) / 3600000);
|
||||
|
||||
return (
|
||||
<div className="bg-emerald-50 dark:bg-emerald-950/40 border border-emerald-200 dark:border-emerald-800 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold text-emerald-700 dark:text-emerald-400 uppercase tracking-wide">
|
||||
Depuis votre dernière visite · {elapsedH}h
|
||||
</span>
|
||||
<button onClick={() => setDismissed(true)} className="text-emerald-400 hover:text-emerald-600 transition">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-6">
|
||||
{feeds > 0 && (
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{feeds}</div>
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">repas</div>
|
||||
</div>
|
||||
)}
|
||||
{diapers > 0 && (
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{diapers}</div>
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">couches</div>
|
||||
</div>
|
||||
)}
|
||||
{sleepMs > 0 && (
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-700 dark:text-emerald-400">{sleepH}h</div>
|
||||
<div className="text-[11px] text-emerald-600 dark:text-emerald-500">sommeil</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinnedNoteWidget() {
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -277,6 +432,7 @@ export default function DashboardPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
||||
const { selectedBaby, isLoading: familyLoading } = useBaby();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const { data: eventsData } = useQuery({
|
||||
queryKey: ["events", selectedBaby?.id],
|
||||
@@ -353,9 +509,13 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned note */}
|
||||
<div className="mb-6">
|
||||
{/* Handoff summary */}
|
||||
<HandoffWidget events={events} userId={session?.user?.id ?? ""} />
|
||||
|
||||
{/* Pinned note + Quick note */}
|
||||
<div className="grid sm:grid-cols-2 gap-4 mb-6">
|
||||
<PinnedNoteWidget />
|
||||
<QuickNoteWidget babyId={selectedBaby.id} />
|
||||
</div>
|
||||
|
||||
{/* Today's summary counters */}
|
||||
|
||||
Reference in New Issue
Block a user