Files
Grow/src/app/(app)/reminders/page.tsx
T
arnaudne 6dc09fb696 fix: UX audit — code-review findings
- Photo client-side limit: 5MB → 30MB (matches server)
- OTHER event shows notes preview in dashboard + timeline summaries
- Stats: add "Activités par jour" chart (Bain/Balade/Plat ventre/Autre)
- Combined diaper (wet→stool): show color/amount pickers, pass metadata
- Quick-add FAB: 4-col → 5-col grid (14 types in 3 rows instead of 4)
- Stool color buttons: force dark text, readable on light bg in dark mode
- Reminders: mark taken (✓) sets lastSentAt, ↺ clears it; shows elapsed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:06:54 +02:00

247 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { Plus, Trash2, Bell, BellOff, Clock, CheckCircle2, RotateCcw } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
interface Reminder {
id: string;
name: string;
dose: string | null;
unit: string | null;
intervalHours: number;
startAt: string;
enabled: boolean;
lastSentAt: string | 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 dark:text-slate-100";
function localNowString() {
const now = new Date();
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function nextDueLabel(reminder: Reminder): string {
const intervalMs = reminder.intervalHours * 60 * 60 * 1000;
const base = reminder.lastSentAt ?? reminder.startAt;
const next = new Date(new Date(base).getTime() + intervalMs);
const now = new Date();
const diff = next.getTime() - now.getTime();
if (diff <= 0) return "En attente d'envoi";
const h = Math.floor(diff / 3600000);
const m = Math.floor((diff % 3600000) / 60000);
return h > 0 ? `Dans ${h}h${m.toString().padStart(2, "0")}` : `Dans ${m} min`;
}
export default function RemindersPage() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ name: "", dose: "", unit: "mL", intervalHours: "8", startAt: localNowString() });
const [saving, setSaving] = useState(false);
const { data: reminders = [], isLoading } = useQuery<Reminder[]>({
queryKey: ["reminders", selectedBaby?.id],
queryFn: () => fetch(`/api/reminders?babyId=${selectedBaby!.id}`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
});
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!selectedBaby) return;
setSaving(true);
await fetch("/api/reminders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId: selectedBaby.id, ...form, intervalHours: parseFloat(form.intervalHours) }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
setForm({ name: "", dose: "", unit: "mL", intervalHours: "8", startAt: localNowString() });
setShowForm(false);
setSaving(false);
}
async function toggleEnabled(id: string, enabled: boolean) {
await fetch(`/api/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !enabled }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function markTaken(id: string) {
await fetch(`/api/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastSentAt: new Date().toISOString() }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function unmarkTaken(id: string) {
await fetch(`/api/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lastSentAt: null }),
});
qc.invalidateQueries({ queryKey: ["reminders"] });
}
async function deleteReminder(id: string) {
if (!confirm("Supprimer ce rappel ?")) return;
await fetch(`/api/reminders/${id}`, { method: "DELETE" });
qc.invalidateQueries({ queryKey: ["reminders"] });
}
return (
<div className="p-4 md:p-8 pb-28 md:pb-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Rappels médicaments</h1>
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
</div>
<button
onClick={() => setShowForm((v) => !v)}
className="flex items-center gap-2 px-3 py-2 bg-indigo-600 text-white rounded-xl text-sm font-medium hover:bg-indigo-700 transition"
>
<Plus className="w-4 h-4" />
Nouveau
</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-4 mb-4 space-y-3">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Nouveau rappel</p>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Médicament</label>
<input type="text" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required className={inputCls} placeholder="Doliprane" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Dose</label>
<input type="text" value={form.dose} onChange={(e) => setForm((f) => ({ ...f, dose: e.target.value }))}
className={inputCls} placeholder="2.4" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Unité</label>
<select value={form.unit} onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))} className={inputCls}>
<option value="mL">mL</option>
<option value="mg">mg</option>
<option value="gouttes">gouttes</option>
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Intervalle (heures)</label>
<select value={form.intervalHours} onChange={(e) => setForm((f) => ({ ...f, intervalHours: e.target.value }))} className={inputCls}>
{[4, 6, 8, 12, 24].map((h) => (
<option key={h} value={h}>{h}h</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Première prise</label>
<input type="datetime-local" value={form.startAt} onChange={(e) => setForm((f) => ({ ...f, startAt: e.target.value }))}
required className={inputCls} />
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={() => setShowForm(false)}
className="flex-1 py-2 border border-slate-200 dark:border-slate-700 rounded-lg text-sm text-slate-600 dark:text-slate-400">
Annuler
</button>
<button type="submit" disabled={saving}
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{saving ? "..." : "Créer"}
</button>
</div>
</form>
)}
{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" />
</div>
)}
{!isLoading && reminders.length === 0 && !showForm && (
<div className="text-center py-16 text-slate-400 dark:text-slate-500">
<Bell className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p className="text-sm">Aucun rappel configuré</p>
</div>
)}
<div className="space-y-3">
{reminders.map((r) => (
<div key={r.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${r.enabled ? "border-slate-200 dark:border-slate-700" : "border-slate-100 dark:border-slate-800 opacity-60"}`}>
<div className="flex items-start gap-3">
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${r.enabled ? "bg-green-50 dark:bg-green-950" : "bg-slate-100 dark:bg-slate-800"}`}>
{r.enabled ? <Bell className="w-4 h-4 text-green-600 dark:text-green-400" /> : <BellOff className="w-4 h-4 text-slate-400" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-100">{r.name}</p>
{(r.dose || r.unit) && (
<p className="text-xs text-slate-500 dark:text-slate-400">{[r.dose, r.unit].filter(Boolean).join(" ")}</p>
)}
<div className="flex items-center gap-1.5 mt-1">
<Clock className="w-3 h-3 text-slate-400" />
<span className="text-xs text-slate-400 dark:text-slate-500">Toutes les {r.intervalHours}h</span>
{r.enabled && (
<span className="text-xs text-indigo-600 dark:text-indigo-400 ml-2">{nextDueLabel(r)}</span>
)}
</div>
{r.lastSentAt && (
<p className="text-xs text-green-600 dark:text-green-400 mt-0.5 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Pris {formatDistanceToNow(new Date(r.lastSentAt), { locale: fr, addSuffix: true })}
</p>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{r.lastSentAt ? (
<button
onClick={() => unmarkTaken(r.id)}
title="Annuler la prise"
className="w-8 h-8 flex items-center justify-center rounded-lg text-green-500 hover:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
) : (
<button
onClick={() => markTaken(r.id)}
title="Marquer comme pris"
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-950 transition"
>
<CheckCircle2 className="w-4 h-4" />
</button>
)}
<button
onClick={() => toggleEnabled(r.id, r.enabled)}
className={`w-9 h-5 rounded-full transition relative overflow-hidden ${r.enabled ? "bg-green-500" : "bg-slate-200 dark:bg-slate-700"}`}
>
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${r.enabled ? "translate-x-[14px]" : "translate-x-0"}`} />
</button>
<button onClick={() => deleteReminder(r.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950 transition">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
{reminders.length > 0 && (
<p className="mt-6 text-center text-xs text-slate-400 dark:text-slate-500">
Appelez <code className="bg-slate-100 dark:bg-slate-800 px-1 py-0.5 rounded">/api/reminders/check</code> régulièrement (cron) pour envoyer les rappels via Pushover.
</p>
)}
</div>
);
}