feat: maintenance banner, activities log, pinned note, event templates, weekly summary cron
Maintenance mode banner: - App layout reads SystemConfig maintenance_mode at server render time - Amber sticky banner for regular users, dimmed indicator for superadmin Baby activities (3 new event types): - BATH (Bath icon, sky, timer+duration) - WALK / Balade (Footprints icon, lime) - TUMMY_TIME (Baby icon, amber) - Added to EventType enum, EVENT_CONFIG, event-icon.tsx, dashboard quick-log Pinned family note: - Shared across all family members, shown at top of dashboard - Inline edit with author + timestamp; /api/family/pinned-note GET/PATCH - pinnedNote + pinnedNoteUpdatedAt + pinnedNoteAuthor fields on Family model Event templates: - Named quick-log presets per family (type + label + metadata) - Dashboard row: tap to open pre-filled EventModal; hover ✕ to delete - Dropdown to create new template; /api/event-templates GET/POST + /[id] PATCH/DELETE - EventTemplate model in schema Weekly summary cron: - POST /api/cron/weekly-summary — auth via Bearer cron_secret (SystemConfig or env) - Per family, per baby: feeds count, sleep total+avg, diaper count, latest weight - Sends Pushover to all family members with pushoverUserKey - Optional body.familyId to target one family Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-5
@@ -56,14 +56,16 @@
|
||||
|
||||
🔲 Offline support / PWA install prompt — service worker for offline event logging
|
||||
🔲 Multi-language UI (FR/EN toggle) — see also pending Bilingual UI
|
||||
🔲 Weekly summary push notification (Sunday digest: feedings, sleep, weight change)
|
||||
✅ Weekly summary cron — POST /api/cron/weekly-summary, 7-day digest per baby (feedings, sleep, diapers, weight), Pushover to family; auth via Bearer cron_secret
|
||||
✅ Baby activities log — BATH, WALK, TUMMY_TIME event types (timer + duration)
|
||||
✅ Pinned family note — shared editable note on dashboard; /api/family/pinned-note
|
||||
✅ Event templates — named quick-log presets; create/delete from dashboard; /api/event-templates
|
||||
✅ Maintenance mode banner — layout reads SystemConfig; amber banner for users, indicator for superadmin
|
||||
🔲 Growth alert: no weight log in 30 days → push notification
|
||||
🔲 Feeding pattern AI insight (detect sleep regression, feeding cluster, etc.)
|
||||
🔲 Contacts / care team directory (pediatrician, midwife, family contact numbers)
|
||||
🔲 Baby activities log (bath, walk, tummy time) — lightweight custom event types
|
||||
🔲 Sleep log chart — visual night timeline (bar per night, wake markers)
|
||||
🔲 Bottle feed from milk stock — direct link: log BOTTLE → decrement milk stock
|
||||
🔲 Shared family notes / pinned message (daily handoff summary visible to all members)
|
||||
🔲 Event templates / quick presets (e.g. "usual nap 13:00–14:30")
|
||||
🔲 Data retention policy — auto-archive events older than N months
|
||||
🔲 Maintenance mode banner in layout (config key exists, layout not wired yet)
|
||||
🔲 Offline support / PWA install prompt
|
||||
🔲 Multi-language UI (FR/EN toggle)
|
||||
|
||||
+24
-6
@@ -85,12 +85,27 @@ model VerificationToken {
|
||||
}
|
||||
|
||||
model Family {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
inviteCode String @unique @default(cuid())
|
||||
users User[]
|
||||
babies Baby[]
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
inviteCode String @unique @default(cuid())
|
||||
users User[]
|
||||
babies Baby[]
|
||||
eventTemplates EventTemplate[]
|
||||
pinnedNote String?
|
||||
pinnedNoteUpdatedAt DateTime?
|
||||
pinnedNoteAuthor String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model EventTemplate {
|
||||
id String @id @default(cuid())
|
||||
familyId String
|
||||
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
type EventType
|
||||
metadata Json?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Baby {
|
||||
@@ -136,6 +151,9 @@ enum EventType {
|
||||
NIGHT_SLEEP
|
||||
MEDICATION
|
||||
TEMPERATURE
|
||||
BATH
|
||||
WALK
|
||||
TUMMY_TIME
|
||||
}
|
||||
|
||||
model GrowthLog {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { EVENT_CONFIG, EventType, formatTimeAgo, formatDuration } from "@/lib/event-config";
|
||||
import { EventModal } from "@/components/event-modal";
|
||||
@@ -9,7 +9,7 @@ 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 } from "lucide-react";
|
||||
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
const QUICK_LOG_TYPES: EventType[] = [
|
||||
"BREASTFEED",
|
||||
@@ -21,6 +21,9 @@ const QUICK_LOG_TYPES: EventType[] = [
|
||||
"NIGHT_SLEEP",
|
||||
"MEDICATION",
|
||||
"TEMPERATURE",
|
||||
"BATH",
|
||||
"WALK",
|
||||
"TUMMY_TIME",
|
||||
];
|
||||
|
||||
interface Event {
|
||||
@@ -33,6 +36,13 @@ interface Event {
|
||||
user: { id: string; name: string };
|
||||
}
|
||||
|
||||
interface EventTemplate {
|
||||
id: string;
|
||||
label: string;
|
||||
type: EventType;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function getBabyAge(birthDate: string): string {
|
||||
const birth = new Date(birthDate);
|
||||
const now = new Date();
|
||||
@@ -63,6 +73,210 @@ function getEventSummary(event: Event): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function PinnedNoteWidget() {
|
||||
const qc = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { data } = useQuery<{ note: string | null; updatedAt: string | null; author: string | null }>({
|
||||
queryKey: ["pinned-note"],
|
||||
queryFn: () => fetch("/api/family/pinned-note").then((r) => r.json()),
|
||||
});
|
||||
|
||||
function startEdit() {
|
||||
setDraft(data?.note ?? "");
|
||||
setEditing(true);
|
||||
setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
await fetch("/api/family/pinned-note", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note: draft }),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["pinned-note"] });
|
||||
setEditing(false);
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
const hasNote = !!data?.note;
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Pin className="w-3.5 h-3.5 text-amber-600 dark:text-amber-400" />
|
||||
<span className="text-xs font-semibold text-amber-700 dark:text-amber-400 uppercase tracking-wide">Note épinglée</span>
|
||||
</div>
|
||||
{!editing && (
|
||||
<button onClick={startEdit}
|
||||
className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition">
|
||||
<Pencil className="w-3 h-3" />
|
||||
{hasNote ? "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-amber-300 dark:border-amber-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-400 resize-none"
|
||||
placeholder="Message pour toute la famille..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500 hover:bg-amber-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-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-400 text-xs rounded-lg transition hover:bg-amber-100 dark:hover:bg-amber-950">
|
||||
<X className="w-3 h-3" />
|
||||
Annuler
|
||||
</button>
|
||||
{hasNote && (
|
||||
<button onClick={async () => { await fetch("/api/family/pinned-note", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ note: "" }) }); qc.invalidateQueries({ queryKey: ["pinned-note"] }); setEditing(false); }}
|
||||
className="ml-auto flex items-center gap-1 text-xs text-red-400 hover:text-red-600 transition">
|
||||
<PinOff className="w-3 h-3" />
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : hasNote ? (
|
||||
<div>
|
||||
<p className="text-sm text-amber-900 dark:text-amber-100 whitespace-pre-wrap">{data!.note}</p>
|
||||
{data?.author && (
|
||||
<p className="text-[10px] text-amber-500 dark:text-amber-500 mt-1.5">
|
||||
{data.author}{data.updatedAt ? ` · ${format(new Date(data.updatedAt), "d MMM HH:mm", { locale: fr })}` : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-amber-500 dark:text-amber-600 italic">Aucune note. Cliquez sur Ajouter pour laisser un message à la famille.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesRow({ babyId, onSaved }: { babyId: string; onSaved: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const [activeTemplate, setActiveTemplate] = useState<EventTemplate | null>(null);
|
||||
const [addingType, setAddingType] = useState<EventType | null>(null);
|
||||
const [addLabel, setAddLabel] = useState("");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const { data: templates = [] } = useQuery<EventTemplate[]>({
|
||||
queryKey: ["event-templates"],
|
||||
queryFn: () => fetch("/api/event-templates").then((r) => r.json()),
|
||||
});
|
||||
|
||||
async function deleteTemplate(id: string) {
|
||||
setDeletingId(id);
|
||||
await fetch(`/api/event-templates/${id}`, { method: "DELETE" });
|
||||
qc.invalidateQueries({ queryKey: ["event-templates"] });
|
||||
setDeletingId(null);
|
||||
}
|
||||
|
||||
async function createTemplate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!addingType || !addLabel.trim()) return;
|
||||
await fetch("/api/event-templates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ type: addingType, label: addLabel.trim() }),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ["event-templates"] });
|
||||
setAddingType(null);
|
||||
setAddLabel("");
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modèles rapides</h2>
|
||||
<div className="relative">
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => { if (e.target.value) { setAddingType(e.target.value as EventType); setAddLabel(""); } }}
|
||||
className="text-xs text-indigo-600 dark:text-indigo-400 border-none bg-transparent appearance-none cursor-pointer focus:outline-none pr-1"
|
||||
>
|
||||
<option value="">+ Nouveau modèle</option>
|
||||
{(Object.keys(EVENT_CONFIG) as EventType[]).map((t) => (
|
||||
<option key={t} value={t}>{EVENT_CONFIG[t].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addingType && (
|
||||
<form onSubmit={createTemplate} className="flex gap-2 mb-3">
|
||||
<div className={`w-8 h-8 rounded-lg ${EVENT_CONFIG[addingType].bgClass} flex items-center justify-center flex-shrink-0`}>
|
||||
<EventIcon name={EVENT_CONFIG[addingType].icon} className={`w-4 h-4 ${EVENT_CONFIG[addingType].colorClass}`} />
|
||||
</div>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={addLabel}
|
||||
onChange={(e) => setAddLabel(e.target.value)}
|
||||
placeholder={`Nom du modèle (ex: Sieste midi)`}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-sm bg-white dark:bg-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
|
||||
/>
|
||||
<button type="submit" className="px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-medium">OK</button>
|
||||
<button type="button" onClick={() => setAddingType(null)} className="px-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg text-xs text-slate-500">✕</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
{templates.map((t) => {
|
||||
const cfg = EVENT_CONFIG[t.type];
|
||||
return (
|
||||
<div key={t.id} className="flex-shrink-0 flex items-center gap-1 group">
|
||||
<button
|
||||
onClick={() => setActiveTemplate(t)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl border text-xs font-medium transition active:scale-95 ${cfg.bgClass} border-transparent hover:border-slate-200 dark:hover:border-slate-600`}
|
||||
>
|
||||
<EventIcon name={cfg.icon} className={`w-3.5 h-3.5 ${cfg.colorClass}`} />
|
||||
<span className="text-slate-700 dark:text-slate-200">{t.label}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteTemplate(t.id)}
|
||||
disabled={deletingId === t.id}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-slate-300 hover:text-red-400 dark:text-slate-600 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templates.length === 0 && !addingType && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 italic">Aucun modèle. Créez-en un pour accélérer la saisie.</p>
|
||||
)}
|
||||
|
||||
{activeTemplate && (
|
||||
<EventModal
|
||||
type={activeTemplate.type}
|
||||
babyId={babyId}
|
||||
onClose={() => setActiveTemplate(null)}
|
||||
onSaved={() => { onSaved(); setActiveTemplate(null); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const qc = useQueryClient();
|
||||
const [activeModal, setActiveModal] = useState<EventType | null>(null);
|
||||
@@ -143,6 +357,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned note */}
|
||||
<div className="mb-6">
|
||||
<PinnedNoteWidget />
|
||||
</div>
|
||||
|
||||
{/* Today's summary counters */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).map((t) => {
|
||||
@@ -210,6 +429,11 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event templates */}
|
||||
<div className="mt-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<TemplatesRow babyId={selectedBaby.id} onSaved={() => qc.invalidateQueries({ queryKey: ["events"] })} />
|
||||
</div>
|
||||
|
||||
{/* Medication status */}
|
||||
{medProfiles.length > 0 && (() => {
|
||||
const activeMeds = medProfiles.filter((p) => lastIntakes[p.name]);
|
||||
|
||||
@@ -3,16 +3,42 @@ import { redirect } from "next/navigation";
|
||||
import { SidebarNav, BottomNav } from "@/components/nav";
|
||||
import { BabyProvider } from "@/contexts/baby-context";
|
||||
import { QuickAddFab } from "@/components/quick-add-fab";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
async function getMaintenanceMode(): Promise<boolean> {
|
||||
try {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { key: "maintenance_mode" } });
|
||||
return row?.value === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/auth/login");
|
||||
|
||||
const maintenance = await getMaintenanceMode();
|
||||
const isSuperAdmin = (session.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
|
||||
|
||||
return (
|
||||
<BabyProvider>
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{maintenance && (
|
||||
<div className={`fixed top-0 left-0 right-0 z-[100] px-4 py-2.5 flex items-center gap-2 justify-center text-sm font-medium shadow-md ${
|
||||
isSuperAdmin
|
||||
? "bg-amber-400/90 text-amber-900 text-xs py-1.5"
|
||||
: "bg-amber-500 text-white"
|
||||
}`}>
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
{isSuperAdmin
|
||||
? "Mode maintenance actif (visible des utilisateurs)"
|
||||
: "Maintenance en cours — certaines fonctionnalités peuvent être indisponibles"}
|
||||
</div>
|
||||
)}
|
||||
<SidebarNav />
|
||||
<main className="app-main min-h-screen">
|
||||
<main className={`app-main min-h-screen${maintenance ? " pt-10" : ""}`}>
|
||||
{children}
|
||||
</main>
|
||||
<BottomNav />
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { sendPushover } from "@/lib/push-notify";
|
||||
import { formatDuration } from "@/lib/event-config";
|
||||
|
||||
// Call weekly (e.g. Sunday 20:00) with:
|
||||
// POST /api/cron/weekly-summary
|
||||
// Authorization: Bearer <CRON_SECRET>
|
||||
// Body: {} (optional: { familyId } to target one family)
|
||||
|
||||
async function getCronSecret(): Promise<string> {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { key: "cron_secret" } });
|
||||
return row?.value || process.env.CRON_SECRET || "";
|
||||
}
|
||||
|
||||
function fmtMs(ms: number): string {
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h > 0) return `${h}h${m.toString().padStart(2, "0")}`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const secret = await getCronSecret();
|
||||
if (secret) {
|
||||
const auth = req.headers.get("authorization") ?? "";
|
||||
if (auth !== `Bearer ${secret}`) {
|
||||
return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const targetFamilyId: string | undefined = body.familyId;
|
||||
|
||||
const since = new Date(Date.now() - 7 * 24 * 3600 * 1000);
|
||||
|
||||
const families = await prisma.family.findMany({
|
||||
where: targetFamilyId ? { id: targetFamilyId } : undefined,
|
||||
include: {
|
||||
babies: true,
|
||||
users: { where: { pushoverUserKey: { not: null } }, select: { pushoverUserKey: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
|
||||
for (const family of families) {
|
||||
if (family.users.length === 0) continue;
|
||||
|
||||
const lines: string[] = [`📊 Résumé semaine — ${family.name}`, ""];
|
||||
|
||||
for (const baby of family.babies) {
|
||||
lines.push(`👶 ${baby.name}`);
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { babyId: baby.id, startedAt: { gte: since } },
|
||||
orderBy: { startedAt: "asc" },
|
||||
});
|
||||
|
||||
// Feedings
|
||||
const feeds = events.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE");
|
||||
if (feeds.length > 0) {
|
||||
const perDay = (feeds.length / 7).toFixed(1);
|
||||
lines.push(`🍼 Repas: ${feeds.length} au total (${perDay}/j)`);
|
||||
}
|
||||
|
||||
// Sleep
|
||||
const sleepEvents = events.filter(
|
||||
(e) => (e.type === "NAP" || e.type === "NIGHT_SLEEP") && e.endedAt
|
||||
);
|
||||
if (sleepEvents.length > 0) {
|
||||
const totalSleepMs = sleepEvents.reduce((acc, e) => {
|
||||
return acc + (new Date(e.endedAt!).getTime() - new Date(e.startedAt).getTime());
|
||||
}, 0);
|
||||
const avgPerDay = totalSleepMs / 7;
|
||||
lines.push(`😴 Sommeil: ${fmtMs(totalSleepMs)} total, ~${fmtMs(avgPerDay)}/j`);
|
||||
}
|
||||
|
||||
// Diapers
|
||||
const diapers = events.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL");
|
||||
if (diapers.length > 0) {
|
||||
lines.push(`👶 Couches: ${diapers.length} (${(diapers.length / 7).toFixed(1)}/j)`);
|
||||
}
|
||||
|
||||
// Weight (latest growth log in the period)
|
||||
const latestWeight = await prisma.growthLog.findFirst({
|
||||
where: { babyId: baby.id, date: { gte: since }, weight: { not: null } },
|
||||
orderBy: { date: "desc" },
|
||||
});
|
||||
if (latestWeight?.weight) {
|
||||
lines.push(`⚖️ Poids: ${latestWeight.weight} kg`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
const message = lines.join("\n").trim();
|
||||
|
||||
await Promise.allSettled(
|
||||
family.users.map((u) =>
|
||||
sendPushover(u.pushoverUserKey!, message, "Grow — Résumé hebdomadaire")
|
||||
)
|
||||
);
|
||||
sent += family.users.length;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sent, families: families.length });
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
const template = await prisma.eventTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.label !== undefined ? { label: body.label } : {}),
|
||||
...(body.type !== undefined ? { type: body.type } : {}),
|
||||
...(body.metadata !== undefined ? { metadata: body.metadata } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(template);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
await prisma.eventTemplate.delete({ where: { id } });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json([]);
|
||||
|
||||
const templates = await prisma.eventTemplate.findMany({
|
||||
where: { familyId: user.familyId },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return NextResponse.json(templates);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
const template = await prisma.eventTemplate.create({
|
||||
data: {
|
||||
familyId: user.familyId,
|
||||
label: body.label,
|
||||
type: body.type,
|
||||
metadata: body.metadata ?? null,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(template, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json({ note: null });
|
||||
|
||||
const family = await prisma.family.findUnique({
|
||||
where: { id: user.familyId },
|
||||
select: { pinnedNote: true, pinnedNoteUpdatedAt: true, pinnedNoteAuthor: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
note: family?.pinnedNote ?? null,
|
||||
updatedAt: family?.pinnedNoteUpdatedAt ?? null,
|
||||
author: family?.pinnedNoteAuthor ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { familyId: true, name: true },
|
||||
});
|
||||
if (!user?.familyId) return NextResponse.json({ error: "Famille introuvable" }, { status: 404 });
|
||||
|
||||
const { note } = await req.json();
|
||||
|
||||
const family = await prisma.family.update({
|
||||
where: { id: user.familyId },
|
||||
data: {
|
||||
pinnedNote: note || null,
|
||||
pinnedNoteUpdatedAt: new Date(),
|
||||
pinnedNoteAuthor: session.user.name ?? user.name,
|
||||
},
|
||||
select: { pinnedNote: true, pinnedNoteUpdatedAt: true, pinnedNoteAuthor: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
note: family.pinnedNote,
|
||||
updatedAt: family.pinnedNoteUpdatedAt,
|
||||
author: family.pinnedNoteAuthor,
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Pill,
|
||||
Thermometer,
|
||||
Milk,
|
||||
Bath,
|
||||
Footprints,
|
||||
Baby,
|
||||
LucideProps,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -21,6 +24,9 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
|
||||
Pill,
|
||||
Thermometer,
|
||||
Milk,
|
||||
Bath,
|
||||
Footprints,
|
||||
Baby,
|
||||
};
|
||||
|
||||
interface EventIconProps extends LucideProps {
|
||||
|
||||
+34
-1
@@ -7,7 +7,10 @@ export type EventType =
|
||||
| "NAP"
|
||||
| "NIGHT_SLEEP"
|
||||
| "MEDICATION"
|
||||
| "TEMPERATURE";
|
||||
| "TEMPERATURE"
|
||||
| "BATH"
|
||||
| "WALK"
|
||||
| "TUMMY_TIME";
|
||||
|
||||
export const EVENT_CONFIG: Record<
|
||||
EventType,
|
||||
@@ -112,6 +115,36 @@ export const EVENT_CONFIG: Record<
|
||||
hasDuration: false,
|
||||
hasTimer: false,
|
||||
},
|
||||
BATH: {
|
||||
label: "Bain",
|
||||
icon: "Bath",
|
||||
color: "#0284c7",
|
||||
colorClass: "text-sky-600",
|
||||
bgClass: "bg-sky-50",
|
||||
borderClass: "border-sky-200",
|
||||
hasDuration: true,
|
||||
hasTimer: true,
|
||||
},
|
||||
WALK: {
|
||||
label: "Balade",
|
||||
icon: "Footprints",
|
||||
color: "#65a30d",
|
||||
colorClass: "text-lime-600",
|
||||
bgClass: "bg-lime-50",
|
||||
borderClass: "border-lime-200",
|
||||
hasDuration: true,
|
||||
hasTimer: true,
|
||||
},
|
||||
TUMMY_TIME: {
|
||||
label: "Tummy time",
|
||||
icon: "Baby",
|
||||
color: "#d97706",
|
||||
colorClass: "text-amber-600",
|
||||
bgClass: "bg-amber-50",
|
||||
borderClass: "border-amber-200",
|
||||
hasDuration: true,
|
||||
hasTimer: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
|
||||
|
||||
Reference in New Issue
Block a user