feat: daily digest, photo timeline, milk expiry cron, batch delete, duration chart

- /api/cron/daily-digest: last feed/diaper, 24h sleep total, next med due
- /photos: photo grid grouped by day, full-screen lightbox with prev/next
- /api/cron/milk-expiry: push alert for lots expiring within 24h
- Timeline: select mode with checkboxes, confirm + bulk DELETE
- Stats: avg NAP/NIGHT_SLEEP duration trend dual-line chart (30d)
- Nav: Photos link added under "Vie du bébé" drawer group

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 16:31:47 +02:00
parent c34796452c
commit e9c8d7e97e
8 changed files with 539 additions and 12 deletions
+71 -6
View File
@@ -7,7 +7,7 @@ import { EventIcon } from "@/components/event-icon";
import { useBaby } from "@/contexts/baby-context";
import { format, isToday, isYesterday } from "date-fns";
import { fr } from "date-fns/locale";
import { Trash2, StickyNote, Pencil } from "lucide-react";
import { Trash2, StickyNote, Pencil, CheckSquare, Square, XCircle } from "lucide-react";
import Image from "next/image";
import { EventModal } from "@/components/event-modal";
@@ -71,6 +71,8 @@ export default function TimelinePage() {
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
const [filterType, setFilterType] = useState<EventType | "">("");
const [page, setPage] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const { data: eventsData, isLoading } = useQuery({
queryKey: ["events", selectedBaby?.id, filterType, page],
@@ -94,6 +96,22 @@ export default function TimelinePage() {
qc.invalidateQueries({ queryKey: ["events"] });
}
async function deleteSelected() {
if (selected.size === 0) return;
await Promise.all([...selected].map((id) => fetch(`/api/events/${id}`, { method: "DELETE" })));
setSelected(new Set());
setSelectMode(false);
qc.invalidateQueries({ queryKey: ["events"] });
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
async function saveNotes(id: string, notes: string) {
await fetch(`/api/events/${id}`, {
method: "PATCH",
@@ -112,9 +130,22 @@ export default function TimelinePage() {
return (
<div className="p-4 md:p-8 pb-24 md:pb-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
Journal{selectedBaby ? `${selectedBaby.name}` : ""}
</h1>
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Journal{selectedBaby ? `${selectedBaby.name}` : ""}
</h1>
<button
onClick={() => { setSelectMode((v) => !v); setSelected(new Set()); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition ${
selectMode
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600"
}`}
>
<CheckSquare className="w-3.5 h-3.5" />
Sélectionner
</button>
</div>
{/* Filter chips */}
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
<button
@@ -140,6 +171,31 @@ export default function TimelinePage() {
</div>
</div>
{selectMode && (
<div className="flex items-center justify-between mb-4 px-4 py-2.5 bg-indigo-50 dark:bg-indigo-950/50 border border-indigo-200 dark:border-indigo-800 rounded-xl">
<span className="text-sm text-indigo-700 dark:text-indigo-300 font-medium">
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
</span>
<div className="flex gap-2">
<button
onClick={() => { setSelectMode(false); setSelected(new Set()); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-300 bg-white dark:bg-slate-800"
>
<XCircle className="w-3.5 h-3.5" />
Annuler
</button>
<button
onClick={() => { if (confirm(`Supprimer ${selected.size} événement${selected.size > 1 ? "s" : ""} ?`)) deleteSelected(); }}
disabled={selected.size === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg bg-red-600 text-white font-medium disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" />
Supprimer ({selected.size})
</button>
</div>
</div>
)}
{isLoading && (
<div className="flex justify-center py-16">
<div className="w-6 h-6 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" />
@@ -161,12 +217,21 @@ export default function TimelinePage() {
const isExpanded = expandedId === ev.id;
const summary = getEventSummary(ev);
const isSelected = selected.has(ev.id);
return (
<div key={ev.id}>
<div key={ev.id} className={isSelected ? "bg-indigo-50 dark:bg-indigo-950/30" : ""}>
<button
className="w-full text-left flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
onClick={() => setExpandedId(isExpanded ? null : ev.id)}
onClick={() => selectMode ? toggleSelect(ev.id) : setExpandedId(isExpanded ? null : ev.id)}
>
{selectMode && (
<div className="flex-shrink-0">
{isSelected
? <CheckSquare className="w-4 h-4 text-indigo-600 dark:text-indigo-400" />
: <Square className="w-4 h-4 text-slate-300 dark:text-slate-600" />}
</div>
)}
<div className={`w-8 h-8 rounded-lg ${cfg.bgClass} flex items-center justify-center flex-shrink-0`}>
<EventIcon name={cfg.icon} className={`w-4 h-4 ${cfg.colorClass}`} />
</div>