Compare commits

...

15 Commits

Author SHA1 Message Date
arnaudne febf9ed107 feat(diaper,milk): leaking diapers toggle + milk stock stats
- Add 'Débordement' toggle to diaper events (wet + stool), stored as
  metadata.leaked; shown in timeline and dashboard summaries
- Add milk stock stats section to stats page (gated on hasPump):
  summary cards (stock mL, lots, pumped in period), 24h expiry warning,
  location breakdown pills, and daily pump volume bar chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 01:07:06 +02:00
arnaudne 4abee45877 fix(diaper): use dark-mode-aware backgrounds for stool color picker
Light pastel bg colors were unreadable on dark backgrounds. Add darkBg
variants for each stool color and a MutationObserver-based isDark hook
to switch between them at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:45:40 +02:00
arnaudne bdf53980c0 fix(docker): prevent pnpm TTY prompt when BuildKit cache has stale node_modules
pnpm 11 aborts with ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY when it
detects an existing node_modules directory and there is no TTY to confirm
purging it. This happens in Portainer when BuildKit layer cache reuses a
prior deps stage containing node_modules.

- .npmrc: set confirmModulesPurge=false (skip prompt unconditionally)
- Dockerfile deps stage: set CI=true (belt-and-suspenders for pnpm CI mode)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 09:10:33 +02:00
arnaudne b4067c7685 fix(duplicate-day): extract userId before closure to satisfy TS strict null check
session.user is narrowed inside the early-return guard but TypeScript
does not carry that narrowing into the Prisma transaction callback.
Extract to a const immediately after the guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 08:41:56 +02:00
arnaudne d3f1753555 feat(timeline): duplicate a day's events onto another day
POST /api/events/duplicate-day — copies all events from sourceDate to
targetDate, shifting timestamps by the day offset while preserving
time-of-day, duration, notes, and metadata. Ownership-gated per family.

In the timeline, each day header gains a copy icon button. Clicking it
expands an inline date picker; confirming triggers the API and
invalidates the events query.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 07:35:59 +02:00
arnaudne f2cc181ece feat: per-baby breastfeeding and pump feature toggles
Add isBreastfed and hasPump flags to Baby model. When disabled,
hide breastfeeding/pump options from quick-add FAB, dashboard
counters and quick-log groups, and the milk stock nav link.
Settings page gains toggle UI to configure per baby.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:51:41 +02:00
arnaudne 319d70c3a4 fix(growth): round X-axis week labels to 1 decimal
1.7142857142857142sem → 1.7sem

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 11:24:24 +02:00
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
arnaudne da60efd7c1 feat(milk): add stock editing and lot assembly
- Inline edit form per stock (volume, location, expiry, notes)
- Assembly mode: select 2+ lots, merge into one with combined volume
  and earliest expiry date, deletes originals
- POST /api/milk now accepts expiresAt override directly
- Fix OTHER event type icon (add FileText to EventIcon map)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:10:39 +02:00
arnaudne 6f2336ffae feat: improve event logging UX
- Remove quick-template feature from dashboard
- Increase photo upload limit from 5MB to 30MB
- Add combined diaper toggle (wet + stool in one save) to event modal
- Show all event types in quick-add FAB and dashboard quick-log grid
- Add OTHER generic event type for freeform notes (schema + migration)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 22:01:05 +02:00
arnaudne 2d0c44e21d chore(api-docs): use default swagger-ui theme
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:34:38 +02:00
arnaudne 1dac9690f5 security: SSRF prevention, ownership gaps, host header injection, date validation
- webhooks: block SSRF — reject localhost/127.x/169.254.x/10.x/192.168.x
  URLs at creation and silently skip at dispatch time
- medication-profiles/last-intakes: add family ownership check on babyId
- invite/send-email: build invite URL from NEXTAUTH_URL env only;
  drop req.headers.get("origin") to prevent host header injection
- baby POST: validate birthDate is a real date; add required-fields check
- milk POST: validate storedAt before new Date()
- journal GET: validate date filter before new Date()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:33:45 +02:00
arnaudne 432feed88f chore: remove accidentally committed worktree submodule, ignore .claude/worktrees/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:29:35 +02:00
arnaudne 0a12cdfbcc fix: sanitize v1 API pagination inputs and validate growth date
- v1/events GET: offset now clamped ≥0, limit fallback prevents NaN
  being passed to Prisma take/skip
- v1/growth POST: validate date is a valid ISO date before new Date()
  to avoid Invalid Date silently propagating into DB

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:27:27 +02:00
arnaudne 9e32766046 security: second-pass ownership checks + input validation fixes
- medication-profiles/[id]: verify familyId ownership before PATCH/DELETE
- event-templates/[id]: verify familyId ownership before PATCH/DELETE
- notify/push: verify baby.familyId matches session family before push
- events GET: validate type against ALL_EVENT_TYPES allowlist; sanitize
  limit (1–500) and offset (≥0) to prevent NaN/unbounded queries
- v1/summary: fix operator precedence bug in feeds count calculation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 23:25:34 +02:00
40 changed files with 970 additions and 300 deletions
Submodule .claude/worktrees/agent-a31af47fbdb654bea deleted from 8bb048ff56
+1
View File
@@ -42,3 +42,4 @@ next-env.d.ts
/src/generated/prisma
.pnpm-store/
.claude/worktrees/
+1
View File
@@ -3,3 +3,4 @@ onlyBuiltDependencies[]=core-js
onlyBuiltDependencies[]=prisma
onlyBuiltDependencies[]=sharp
onlyBuiltDependencies[]=unrs-resolver
confirmModulesPurge=false
+1
View File
@@ -6,6 +6,7 @@ RUN apk add --no-cache openssl && \
FROM base AS deps
WORKDIR /app
ENV PRISMA_CLI_BINARY_TARGETS=linux-musl-openssl-3.0.x
ENV CI=true
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts
+117 -2
View File
@@ -11,10 +11,12 @@
| Severity | Found | Fixed |
|----------|-------|-------|
| CRITICAL | 9 | 9 |
| HIGH | 6 | 5 |
| MEDIUM | 6 | 2 |
| HIGH | 13 | 12 |
| MEDIUM | 10 | 8 |
| LOW | 1 | 0 |
_Updated after third-pass audit (pass 3 of 3)._
---
## CRITICAL — Fixed
@@ -145,6 +147,119 @@ If `SUPERADMIN_EMAIL` env var is not set, the condition `SUPERADMIN_EMAIL && ses
---
---
## HIGH — Fixed (pass 2)
### 17. `medication-profiles/[id]` — no family ownership check
**Affected:** PATCH and DELETE
**Problem:** Any authenticated user could modify or delete any medication profile by ID.
**Fix:** Added `prisma.medicationProfile.findFirst({ where: { id, familyId } })` check before PATCH and DELETE.
---
### 18. `event-templates/[id]` — no family ownership check
**Affected:** PATCH and DELETE
**Problem:** Any authenticated user could modify or delete any event template by ID.
**Fix:** Added `prisma.eventTemplate.findFirst({ where: { id, familyId } })` check before PATCH and DELETE.
---
### 19. `notify/push` — no family ownership check on babyId
**Problem:** Baby was fetched by ID but its `familyId` was never compared against the session user's family. Any authenticated user could trigger push notifications using another family's baby ID (leaking feeding status via the notification message).
**Fix:** Added `if (baby.familyId !== sessionFamilyId) return 404`.
---
### 20. `events/route.ts` GET — event `type` not validated against allowlist
**Problem:** The `type` query param was passed directly into the Prisma where clause without validation. While Prisma parameterizes values (no SQL injection), arbitrary strings could be stored and returned.
**Fix:** Imported `ALL_EVENT_TYPES` from `event-config.ts` and added `if (type && !ALL_EVENT_TYPES.includes(type)) return 400`.
---
### 21. `events/route.ts` GET — `limit`/`offset` not sanitized
**Problem:** `parseInt()` returns `NaN` on non-numeric input; `NaN` passed to Prisma `take`/`skip` causes an error or is treated as 0, potentially returning all rows.
**Fix:** Added clamping: `limit = Math.max(1, Math.min(parseInt(limit) || 50, 500))`, `offset = Math.max(0, parseInt(offset) || 0)`.
---
## MEDIUM — Fixed (pass 2)
### 22. `v1/summary` — arithmetic operator precedence bug
**Problem:** `counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0)` evaluated as `counts.BREASTFEED ?? (0 + (counts.BOTTLE ?? 0))` due to `??` precedence, causing incorrect feed count.
**Fix:** Added explicit parentheses: `(counts.BREASTFEED ?? 0) + (counts.BOTTLE ?? 0)`.
---
### 23. `v1/events` GET — `offset` not sanitized (pass 2)
**Fix:** `Math.max(0, parseInt(offset) || 0)`. Also fixed `limit`: `Math.min` of `NaN` returns `NaN`; added `|| 50` fallback.
---
### 24. `v1/growth` POST — date not validated before `new Date()`
**Fix:** Added `if (isNaN(new Date(date).getTime())) return 400`.
---
---
## HIGH — Fixed (pass 3)
### 25. SSRF via webhook URL — internal IP/localhost allowed
**Affected:** `webhooks/route.ts` POST (creation) and `lib/webhooks.ts` dispatch
**Problem:** Webhook URLs were accepted without hostname validation. An attacker could register `http://localhost:5432` or `http://169.254.169.254/latest/meta-data/` (AWS metadata endpoint) and cause the server to make requests to internal services, leaking credentials or probing the internal network.
**Fix:** Added `isSafeWebhookUrl()` helper using regex to reject `localhost`, `127.x`, `0.0.0.0`, `::1`, `10.x`, `172.1631.x`, `192.168.x`, `169.254.x`. Applied at both creation (returns 400) and dispatch (skips the hook silently).
---
### 26. `medication-profiles/last-intakes` — missing family ownership check
**Problem:** Accepted any `babyId` without verifying it belongs to the session user's family. Cross-family medication data leak.
**Fix:** Added `prisma.baby.findFirst({ where: { id: babyId, familyId } })` check.
---
## MEDIUM — Fixed (pass 3)
### 27. Host header injection in invite email URL
**Affected:** `invite/send-email/route.ts`
**Problem:** Used `req.headers.get("origin")` to build the invite URL, allowing an attacker to craft a request with `Origin: https://evil.com` and send phishing links to invited users.
**Fix:** Now uses `process.env.NEXTAUTH_URL` exclusively; request Origin header ignored.
---
### 28. `baby/route.ts` POST — birthDate not validated
**Problem:** `new Date(birthDate)` accepted invalid strings silently; also no required-fields check.
**Fix:** Added `!name || !birthDate` check + `isNaN` date validation.
---
### 29. `milk/route.ts` POST — storedAt not validated
### 30. `journal/route.ts` GET — date filter not validated
**Fix:** Added `isNaN(new Date(x).getTime())` guards on both.
---
## MEDIUM — Not applicable (already handled)
### Upload route — extension from MIME type, not filename
The audit flagged `upload/route.ts` for unsafe extension extraction. The route derives the extension from `file.type` (e.g., `"image/jpeg".split("/")[1]`) — not from the original filename — and `ALLOWED_TYPES.includes(file.type)` is checked before that line. No fix needed.
---
## Routes verified as already secure
- `webhooks/route.ts` — familyId from session, no cross-family access possible
@@ -0,0 +1,37 @@
-- AlterEnum
ALTER TYPE "EventType" ADD VALUE 'OTHER';
-- CreateTable
CREATE TABLE "PendingInvite" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'PARENT',
"token" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PendingInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Webhook" (
"id" TEXT NOT NULL,
"familyId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL,
"events" TEXT[],
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PendingInvite_token_key" ON "PendingInvite"("token");
-- AddForeignKey
ALTER TABLE "PendingInvite" ADD CONSTRAINT "PendingInvite_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_familyId_fkey" FOREIGN KEY ("familyId") REFERENCES "Family"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Baby" ADD COLUMN "hasPump" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "isBreastfed" BOOLEAN NOT NULL DEFAULT true;
+3
View File
@@ -150,6 +150,8 @@ model Baby {
name String
birthDate DateTime
gender String?
isBreastfed Boolean @default(true)
hasPump Boolean @default(true)
familyId String
family Family @relation(fields: [familyId], references: [id])
events Event[]
@@ -193,6 +195,7 @@ enum EventType {
WALK
TUMMY_TIME
SYMPTOM
OTHER
}
model GrowthLog {
+26 -140
View File
@@ -9,23 +9,24 @@ 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, FileText, ImageIcon } from "lucide-react";
import { AlertTriangle, Clock, CheckCircle2, Pin, PinOff, Pencil, Check, X, Plus, FileText, ImageIcon } from "lucide-react";
import { useSession } from "next-auth/react";
const QUICK_LOG_GROUPS: { label: string; types: EventType[] }[] = [
{
label: "Alimentation & Soins",
types: ["BREASTFEED", "BOTTLE", "PUMP", "DIAPER_WET", "DIAPER_STOOL"],
},
{
label: "Sommeil & Activités",
types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"],
},
{
label: "Santé",
types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"],
},
];
function buildQuickLogGroups(isBreastfed: boolean, hasPump: boolean): { label: string; types: EventType[] }[] {
const feedTypes: EventType[] = [
...(isBreastfed ? (["BREASTFEED"] as EventType[]) : []),
"BOTTLE",
...(hasPump ? (["PUMP"] as EventType[]) : []),
"DIAPER_WET",
"DIAPER_STOOL",
];
return [
{ label: "Alimentation & Soins", types: feedTypes },
{ label: "Sommeil & Activis", types: ["NAP", "NIGHT_SLEEP", "BATH", "WALK", "TUMMY_TIME"] },
{ label: "Santé", types: ["TEMPERATURE", "MEDICATION", "SYMPTOM"] },
{ label: "Autre", types: ["OTHER"] },
];
}
interface Event {
id: string;
@@ -37,12 +38,6 @@ 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);
@@ -65,13 +60,15 @@ function getEventSummary(event: Event): string {
if (event.type === "BOTTLE") {
return [meta.volume ? `${meta.volume} mL` : "", meta.bottleType === "maternal" ? "maternel" : "artificiel"].filter(Boolean).join(" · ");
}
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if (event.type === "SYMPTOM") { const tags = (meta.tags as string[] | undefined) ?? []; return tags.join(", "); }
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
return "";
}
@@ -384,115 +381,6 @@ function PinnedNoteWidget() {
);
}
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();
@@ -544,7 +432,7 @@ export default function DashboardPage() {
queryKey: ["events-feeds", selectedBaby?.id],
queryFn: () =>
fetch(`/api/events?babyId=${selectedBaby!.id}&limit=100&type=BREASTFEED`).then((r) => r.json()),
enabled: !!selectedBaby?.id,
enabled: !!selectedBaby?.id && (selectedBaby?.isBreastfed ?? true),
staleTime: 60_000,
});
const { data: bottleData } = useQuery({
@@ -687,8 +575,11 @@ export default function DashboardPage() {
</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) => {
<div className={`grid gap-3 mb-6 ${selectedBaby.isBreastfed ? "grid-cols-4" : "grid-cols-3"}`}>
{(["BREASTFEED", "BOTTLE", "DIAPER_WET", "DIAPER_STOOL"] as EventType[]).filter((t) => {
if (t === "BREASTFEED" && !selectedBaby.isBreastfed) return false;
return true;
}).map((t) => {
const cfg = EVENT_CONFIG[t];
return (
<div key={t} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-3 text-center">
@@ -783,7 +674,7 @@ export default function DashboardPage() {
{/* Quick log */}
<div className="space-y-4">
{QUICK_LOG_GROUPS.map((group) => (
{buildQuickLogGroups(selectedBaby.isBreastfed, selectedBaby.hasPump).map((group) => (
<div key={group.label}>
<h2 className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider mb-2">{group.label}</h2>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2">
@@ -809,11 +700,6 @@ export default function DashboardPage() {
))}
</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]);
+4 -3
View File
@@ -104,9 +104,10 @@ function buildSimpleChartData(logs: GrowthLog[], baby: BabyShape, field: "height
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 text-slate-900 dark:text-slate-100";
function fmtAgeWeeks(weeks: number): string {
if (weeks === 0) return "Naiss.";
if (weeks < 4) return `${weeks}sem`;
const months = Math.round(weeks / 4.345);
const w = Math.round(weeks * 10) / 10;
if (w === 0) return "Naiss.";
if (w < 4) return `${w}sem`;
const months = Math.round(w / 4.345);
if (months >= 12) return "1 an";
return `${months}m`;
}
+218 -9
View File
@@ -3,7 +3,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind } from "lucide-react";
import { Plus, Trash2, Check, AlertTriangle, Clock, Thermometer, Snowflake, Wind, Pencil, Combine, X } from "lucide-react";
import { format, formatDistanceToNow } from "date-fns";
import { fr } from "date-fns/locale";
@@ -33,11 +33,15 @@ function localNowString() {
return new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function toLocalDatetime(iso: string) {
const d = new Date(iso);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
}
function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
const now = Date.now();
const exp = new Date(stock.expiresAt).getTime();
if (exp <= now) return "expired";
// "soon" threshold scales with location: room <2h, fridge <24h, freezer <7d
const remaining = exp - now;
const soonMs =
stock.location === "freezer" ? 7 * 24 * 3600_000 :
@@ -50,6 +54,8 @@ function expiryStatus(stock: Stock): "expired" | "soon" | "ok" {
export default function MilkPage() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
// Add form
const [showForm, setShowForm] = useState(false);
const [showUsed, setShowUsed] = useState(false);
const [location, setLocation] = useState<Location>("fridge");
@@ -57,10 +63,25 @@ export default function MilkPage() {
volume: "",
storedAt: localNowString(),
notes: "",
expiryCustom: "", // in hours (room), days (fridge), months (freezer)
expiryCustom: "",
});
const [saving, setSaving] = useState(false);
// Edit
const [editingId, setEditingId] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
volume: "",
location: "fridge" as Location,
expiresAt: "",
notes: "",
});
const [editSaving, setEditSaving] = useState(false);
// Assemble
const [assembleMode, setAssembleMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [assembleSaving, setAssembleSaving] = useState(false);
const { data: stocks = [], isLoading } = useQuery<Stock[]>({
queryKey: ["milk", selectedBaby?.id],
queryFn: () => fetch(`/api/milk?babyId=${selectedBaby!.id}`).then((r) => r.json()),
@@ -108,6 +129,35 @@ export default function MilkPage() {
setSaving(false);
}
function openEdit(s: Stock) {
setEditingId(s.id);
setEditForm({
volume: String(s.volume),
location: (s.location as Location) ?? "fridge",
expiresAt: toLocalDatetime(s.expiresAt),
notes: s.notes ?? "",
});
}
async function handleSaveEdit(e: React.FormEvent) {
e.preventDefault();
if (!editingId) return;
setEditSaving(true);
await fetch(`/api/milk/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
volume: parseFloat(editForm.volume),
location: editForm.location,
expiresAt: new Date(editForm.expiresAt).toISOString(),
notes: editForm.notes || null,
}),
});
qc.invalidateQueries({ queryKey: ["milk"] });
setEditingId(null);
setEditSaving(false);
}
async function markUsed(id: string) {
await fetch(`/api/milk/${id}`, {
method: "PATCH",
@@ -123,6 +173,48 @@ export default function MilkPage() {
qc.invalidateQueries({ queryKey: ["milk"] });
}
function toggleSelect(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const selectedStocks = available.filter((s) => selectedIds.has(s.id));
const assembleVolume = selectedStocks.reduce((sum, s) => sum + s.volume, 0);
const assembleExpiry = selectedStocks.length > 0
? selectedStocks.reduce((min, s) => {
const t = new Date(s.expiresAt).getTime();
return t < min ? t : min;
}, Infinity)
: null;
async function handleAssemble() {
if (!selectedBaby || selectedStocks.length < 2) return;
setAssembleSaving(true);
await fetch("/api/milk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId: selectedBaby.id,
volume: assembleVolume,
storedAt: new Date().toISOString(),
location: selectedStocks[0].location,
notes: `Assemblage de ${selectedStocks.length} lots`,
expiresAt: assembleExpiry ? new Date(assembleExpiry).toISOString() : undefined,
}),
});
await Promise.all(selectedStocks.map((s) =>
fetch(`/api/milk/${s.id}`, { method: "DELETE" })
));
qc.invalidateQueries({ queryKey: ["milk"] });
setSelectedIds(new Set());
setAssembleMode(false);
setAssembleSaving(false);
}
const defaultExpiryHours = LOCATION_CONFIG[location].defaultHours;
return (
@@ -133,6 +225,20 @@ export default function MilkPage() {
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Stock de lait</h1>
{selectedBaby && <p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">{selectedBaby.name}</p>}
</div>
<div className="flex items-center gap-2">
{available.filter((s) => expiryStatus(s) !== "expired").length >= 2 && (
<button
onClick={() => { setAssembleMode((v) => !v); setSelectedIds(new Set()); }}
className={`flex items-center gap-2 px-3 py-2 rounded-xl text-sm font-medium transition ${
assembleMode
? "bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-300 dark:border-indigo-700"
: "border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800"
}`}
>
<Combine className="w-4 h-4" />
Assembler
</button>
)}
<button
onClick={() => setShowForm((v) => !v)}
className="flex items-center gap-2 px-3 py-2 bg-cyan-600 text-white rounded-xl text-sm font-medium hover:bg-cyan-700 transition"
@@ -141,6 +247,7 @@ export default function MilkPage() {
Ajouter
</button>
</div>
</div>
{/* Summary cards */}
{available.length > 0 && (
@@ -174,12 +281,19 @@ export default function MilkPage() {
</div>
)}
{/* Assemble hint */}
{assembleMode && (
<div className="flex items-center gap-2 px-3 py-2.5 bg-indigo-50 dark:bg-indigo-950 border border-indigo-200 dark:border-indigo-800 rounded-xl mb-4 text-sm text-indigo-700 dark:text-indigo-300">
<Combine className="w-4 h-4 flex-shrink-0" />
<span>Sélectionnez au moins 2 lots à assembler</span>
</div>
)}
{/* Add form */}
{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 lot</p>
{/* Location selector */}
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Conservation</label>
<div className="flex gap-2">
@@ -227,9 +341,7 @@ export default function MilkPage() {
value={form.expiryCustom}
onChange={(e) => setForm((f) => ({ ...f, expiryCustom: e.target.value }))}
className={inputCls}
placeholder={
location === "freezer" ? "6" : location === "fridge" ? "4" : "4"
}
placeholder={location === "freezer" ? "6" : location === "fridge" ? "4" : "4"}
min="1"
/>
</div>
@@ -280,13 +392,28 @@ export default function MilkPage() {
.map((s) => {
const status = expiryStatus(s);
const LocIcon = LOCATION_CONFIG[s.location as Location]?.icon ?? Thermometer;
const isEditing = editingId === s.id;
const isSelectable = assembleMode && status !== "expired";
const isSelected = selectedIds.has(s.id);
return (
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl p-4 transition ${
<div key={s.id} className={`bg-white dark:bg-slate-900 border rounded-xl overflow-hidden transition ${
isSelected ? "border-indigo-400 dark:border-indigo-600 ring-1 ring-indigo-300 dark:ring-indigo-700" :
status === "expired" ? "border-red-200 dark:border-red-800" :
status === "soon" ? "border-amber-200 dark:border-amber-800" :
"border-slate-200 dark:border-slate-700"
}`}>
<div className="flex items-start gap-3">
<div
className={`flex items-start gap-3 p-4 ${isSelectable ? "cursor-pointer" : ""}`}
onClick={isSelectable ? () => toggleSelect(s.id) : undefined}
>
{assembleMode && (
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 mt-2 transition ${
isSelected ? "border-indigo-500 bg-indigo-500" : "border-slate-300 dark:border-slate-600"
}`}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
)}
<div className={`w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 ${
status === "expired" ? "bg-red-50 dark:bg-red-950" :
status === "soon" ? "bg-amber-50 dark:bg-amber-950" :
@@ -321,7 +448,13 @@ export default function MilkPage() {
</p>
{s.notes && <p className="text-xs text-slate-400 dark:text-slate-500 italic mt-0.5">{s.notes}</p>}
</div>
{!assembleMode && (
<div className="flex flex-col gap-1.5 flex-shrink-0">
<button onClick={() => isEditing ? setEditingId(null) : openEdit(s)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 dark:hover:bg-indigo-950 transition"
title="Modifier">
{isEditing ? <X className="w-4 h-4" /> : <Pencil className="w-3.5 h-3.5" />}
</button>
<button onClick={() => markUsed(s.id)}
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"
title="Marquer comme utilisé">
@@ -333,13 +466,89 @@ export default function MilkPage() {
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
{/* Inline edit form */}
{isEditing && (
<form onSubmit={handleSaveEdit} className="border-t border-slate-100 dark:border-slate-700 px-4 pb-4 pt-3 space-y-3 bg-slate-50 dark:bg-slate-800/60">
<p className="text-xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">Modifier le lot</p>
<div className="flex gap-2">
{(["room", "fridge", "freezer"] as Location[]).map((loc) => {
const cfg = LOCATION_CONFIG[loc];
const Icon = cfg.icon;
return (
<button key={loc} type="button" onClick={() => setEditForm((f) => ({ ...f, location: loc }))}
className={`flex-1 flex flex-col items-center gap-1 py-2 px-1 border rounded-xl text-xs font-medium transition ${
editForm.location === loc
? "border-indigo-500 bg-indigo-50 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300"
: "border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400"
}`}>
<Icon className={`w-4 h-4 ${editForm.location === loc ? "text-indigo-600 dark:text-indigo-400" : "text-slate-400"}`} />
{loc === "room" ? "Ambiant" : loc === "fridge" ? "Frigo" : "Congélo"}
</button>
);
})}
</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">Volume (mL)</label>
<input type="number" value={editForm.volume} onChange={(e) => setEditForm((f) => ({ ...f, volume: e.target.value }))}
required className={inputCls} min="1" max="2000" />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Péremption</label>
<input type="datetime-local" value={editForm.expiresAt} onChange={(e) => setEditForm((f) => ({ ...f, expiresAt: e.target.value }))}
required className={inputCls} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1.5">Notes</label>
<input type="text" value={editForm.notes} onChange={(e) => setEditForm((f) => ({ ...f, notes: e.target.value }))}
className={inputCls} />
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setEditingId(null)}
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={editSaving}
className="flex-[2] py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium disabled:opacity-50">
{editSaving ? "..." : "Enregistrer"}
</button>
</div>
</form>
)}
</div>
);
})}
</div>
)}
{/* Assemble action bar */}
{assembleMode && selectedIds.size >= 2 && (
<div className="fixed bottom-[calc(env(safe-area-inset-bottom)+4.5rem)] md:bottom-8 left-4 right-4 md:left-auto md:right-8 md:w-80 z-40">
<div className="bg-indigo-600 text-white rounded-2xl shadow-2xl p-4 flex items-center gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{selectedIds.size} lots · {assembleVolume} mL</p>
{assembleExpiry && (
<p className="text-xs text-indigo-200 mt-0.5">
Expire le {format(new Date(assembleExpiry), "dd/MM à HH:mm")}
</p>
)}
</div>
<button
onClick={handleAssemble}
disabled={assembleSaving}
className="flex items-center gap-2 px-4 py-2 bg-white text-indigo-700 rounded-xl text-sm font-semibold hover:bg-indigo-50 transition disabled:opacity-50"
>
<Combine className="w-4 h-4" />
{assembleSaving ? "..." : "Assembler"}
</button>
</div>
</div>
)}
{/* Used / archived */}
{used.length > 0 && (
<div className="mt-6">
+44 -1
View File
@@ -3,7 +3,9 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useBaby } from "@/contexts/baby-context";
import { Plus, Trash2, Bell, BellOff, Clock } from "lucide-react";
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;
@@ -72,6 +74,24 @@ export default function RemindersPage() {
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" });
@@ -175,8 +195,31 @@ export default function RemindersPage() {
<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"}`}
+23 -2
View File
@@ -438,7 +438,7 @@ export default function SettingsPage() {
const [dateTo, setDateTo] = useState("");
const [editingBaby, setEditingBaby] = useState(false);
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "" });
const [babyForm, setBabyForm] = useState({ name: "", birthDate: "", gender: "", isBreastfed: true, hasPump: true });
const [savingBaby, setSavingBaby] = useState(false);
const [thresholds, setThresholdsState] = useState(getThresholds);
@@ -563,13 +563,15 @@ export default function SettingsPage() {
setExportLoading(false);
}
function startEditBaby(b?: { name: string; birthDate: string; gender?: string }) {
function startEditBaby(b?: { name: string; birthDate: string; gender?: string; isBreastfed?: boolean; hasPump?: boolean }) {
const src = b ?? selectedBaby;
if (!src) return;
setBabyForm({
name: src.name,
birthDate: src.birthDate.slice(0, 10),
gender: src.gender ?? "",
isBreastfed: src.isBreastfed !== false,
hasPump: src.hasPump !== false,
});
setEditingBaby(true);
}
@@ -696,6 +698,25 @@ export default function SettingsPage() {
<option value="girl">Fille</option>
</select>
</div>
<div className="space-y-2">
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400">Alimentation</label>
{[
{ key: "isBreastfed" as const, label: "Allaitement maternel" },
{ key: "hasPump" as const, label: "Tire-lait & stock lait" },
].map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => setBabyForm((f) => ({ ...f, [key]: !f[key] }))}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>{label}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${babyForm[key] ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${babyForm[key] ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
))}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setEditingBaby(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 bg-white dark:bg-slate-700">Annuler</button>
<button type="submit" disabled={savingBaby} className="flex-1 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium">
+110 -1
View File
@@ -16,6 +16,17 @@ interface Event {
type: EventType;
startedAt: string;
endedAt?: string;
metadata?: Record<string, unknown>;
}
interface MilkStock {
id: string;
volume: number;
storedAt: string;
expiresAt: string;
location: string;
used: boolean;
usedAt?: string | null;
}
function buildWeeklyData(events: Event[], days: number) {
@@ -30,10 +41,11 @@ function buildWeeklyData(events: Event[], days: number) {
});
const feeds = dayEvents.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
const diapers = dayEvents.filter((e) => e.type === "DIAPER_WET" || e.type === "DIAPER_STOOL").length;
const activities = dayEvents.filter((e) => e.type === "BATH" || e.type === "WALK" || e.type === "TUMMY_TIME" || e.type === "OTHER").length;
const sleepMins = dayEvents
.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()) / 60000, 0);
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, sleep: Math.round(sleepMins / 60 * 10) / 10 };
return { label: format(d, labelFormat, { locale: fr }), feeds, diapers, activities, sleep: Math.round(sleepMins / 60 * 10) / 10 };
});
}
@@ -125,6 +137,19 @@ function buildDurationTrend(events: Event[], days: number) {
});
}
function buildMilkDailyData(events: Event[], days: number) {
const labelFormat = days <= 7 ? "EEE" : "d/M";
return Array.from({ length: days }, (_, i) => {
const d = subDays(new Date(), days - 1 - i);
const key = format(d, "yyyy-MM-dd");
const label = format(d, labelFormat, { locale: fr });
const pumped = events
.filter((e) => e.type === "PUMP" && format(new Date(e.startedAt), "yyyy-MM-dd") === key)
.reduce((acc, e) => acc + (typeof e.metadata?.volume === "number" ? e.metadata.volume : 0), 0);
return { label, pumped: Math.round(pumped) };
});
}
function useDarkMode() {
const [isDark, setIsDark] = useState(false);
useEffect(() => {
@@ -365,6 +390,12 @@ export default function StatsPage() {
enabled: !!baby?.id,
});
const { data: milkStocks } = useQuery<MilkStock[]>({
queryKey: ["milk", baby?.id],
queryFn: () => fetch(`/api/milk?babyId=${baby!.id}`).then((r) => r.json()),
enabled: !!baby?.id && !!baby?.hasPump,
});
const events: Event[] = eventsData?.events ?? [];
const hitLimit = events.length >= 500;
const weeklyData = buildWeeklyData(events, days);
@@ -374,6 +405,20 @@ export default function StatsPage() {
const feedingStats = computeFeedingStats(events);
const diaperStreak = computeDiaperStreak(events, days);
const milkDailyData = buildMilkDailyData(events, days);
const totalPumpedMl = milkDailyData.reduce((acc, d) => acc + d.pumped, 0);
const availableStocks = (milkStocks ?? []).filter((s) => !s.used);
const totalStockMl = availableStocks.reduce((acc, s) => acc + s.volume, 0);
const stockByLocation = availableStocks.reduce<Record<string, number>>((acc, s) => {
acc[s.location] = (acc[s.location] ?? 0) + s.volume;
return acc;
}, {});
const LOCATION_LABELS: Record<string, string> = { room: "Ambiant", fridge: "Frigo", freezer: "Congélateur" };
const LOCATION_COLORS: Record<string, string> = { room: "text-amber-600", fridge: "text-cyan-600", freezer: "text-indigo-600" };
const expiringSoon = availableStocks.filter((s) => {
const diff = new Date(s.expiresAt).getTime() - Date.now();
return diff > 0 && diff < 24 * 3600000;
}).length;
const today = events.filter((e) => new Date(e.startedAt).toDateString() === new Date().toDateString());
const todayFeeds = today.filter((e) => e.type === "BREASTFEED" || e.type === "BOTTLE").length;
@@ -507,6 +552,56 @@ export default function StatsPage() {
</div>
</div>
{/* Milk stock stats */}
{baby?.hasPump && (
<div className="mb-4 space-y-4">
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200 mb-1">Stock de lait</p>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">Lots disponibles · {periodSubtitle(days)}</p>
<div className="grid grid-cols-3 gap-2 md:gap-4 mb-4">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">En stock</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{Math.round(totalStockMl)} <span className="text-sm font-normal text-slate-400">mL</span></p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Lots dispo</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{availableStocks.length}</p>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">Pompé (période)</p>
<p className="text-lg font-bold text-cyan-600 leading-tight">{totalPumpedMl} <span className="text-sm font-normal text-slate-400">mL</span></p>
</div>
</div>
{expiringSoon > 0 && (
<div className="mb-4 px-3 py-2 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-lg text-xs text-amber-700 dark:text-amber-300">
{expiringSoon} lot{expiringSoon > 1 ? "s" : ""} expire{expiringSoon > 1 ? "nt" : ""} dans moins de 24h
</div>
)}
{Object.keys(stockByLocation).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(stockByLocation).map(([loc, vol]) => (
<span key={loc} className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-slate-100 dark:bg-slate-700 ${LOCATION_COLORS[loc] ?? "text-slate-600"}`}>
{LOCATION_LABELS[loc] ?? loc} · {Math.round(vol)} mL
</span>
))}
</div>
)}
</div>
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Volume pompé par jour</h3>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-4">mL · {periodSubtitle(days)}</p>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={milkDailyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<Tooltip formatter={(val) => [`${val} mL`, "Pompé"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="pumped" fill="#0891b2" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="grid md:grid-cols-2 gap-4">
{/* Feeds */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
@@ -534,6 +629,20 @@ export default function StatsPage() {
</ResponsiveContainer>
</div>
{/* Activities */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1">Activités par jour</h3>
<p className="text-[10px] text-slate-400 dark:text-slate-500 mb-4">Bain · Balade · Plat ventre · Autre</p>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{ top: 0, right: 0, left: -20, bottom: 0 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: "#94a3b8" }} axisLine={false} tickLine={false} allowDecimals={false} />
<Tooltip formatter={(val) => [val, "Activités"]} contentStyle={tooltipStyle} cursor={{ fill: cursorFill }} />
<Bar dataKey="activities" fill="#65a30d" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
{/* Sleep */}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4 md:col-span-2">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-4">Sommeil par jour (heures)</h3>
+69 -2
View File
@@ -8,7 +8,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, CheckSquare, Square, XCircle, ImageIcon } from "lucide-react";
import { Trash2, StickyNote, Pencil, CheckSquare, Square, XCircle, ImageIcon, Copy } from "lucide-react";
import Image from "next/image";
import { EventModal } from "@/components/event-modal";
@@ -55,7 +55,8 @@ function getEventSummary(event: Event): string {
if (event.endedAt) parts.unshift(formatDuration(new Date(event.startedAt), new Date(event.endedAt)));
return parts.filter(Boolean).join(" · ");
}
if (event.type === "DIAPER_STOOL") return String(meta.color ?? "");
if (event.type === "DIAPER_STOOL") return [String(meta.color ?? ""), meta.leaked ? "débordement" : ""].filter(Boolean).join(" · ");
if (event.type === "DIAPER_WET") return meta.leaked ? "débordement" : "";
if (event.type === "TEMPERATURE") return meta.value ? `${meta.value}°${meta.unit ?? "C"}` : "";
if (event.type === "MEDICATION") return String(meta.name ?? "");
if (event.type === "SYMPTOM") {
@@ -65,6 +66,7 @@ function getEventSummary(event: Event): string {
if ((event.type === "NAP" || event.type === "NIGHT_SLEEP") && event.endedAt) {
return formatDuration(new Date(event.startedAt), new Date(event.endedAt));
}
if (event.type === "OTHER") return event.notes ? event.notes.slice(0, 50) : "";
return "";
}
@@ -82,6 +84,10 @@ export default function TimelinePage() {
const [page, setPage] = useState(0);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [duplicating, setDuplicating] = useState<string | null>(null); // sourceDate being duplicated
const [dupTarget, setDupTarget] = useState(""); // target date string
const [dupLoading, setDupLoading] = useState(false);
const [dupError, setDupError] = useState("");
const { data: eventsData, isLoading } = useQuery({
queryKey: ["events", selectedBaby?.id, filterType, page],
@@ -105,6 +111,27 @@ export default function TimelinePage() {
qc.invalidateQueries({ queryKey: ["events"] });
}
async function duplicateDay(sourceDate: string, targetDate: string) {
if (!selectedBaby?.id) return;
setDupLoading(true);
setDupError("");
const res = await fetch("/api/events/duplicate-day", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ babyId: selectedBaby.id, sourceDate, targetDate }),
});
setDupLoading(false);
if (!res.ok) {
const data = await res.json();
setDupError(data.error ?? "Erreur");
return;
}
setDuplicating(null);
setDupTarget("");
setDupError("");
qc.invalidateQueries({ queryKey: ["events"] });
}
async function deleteSelected() {
if (selected.size === 0) return;
await Promise.all([...selected].map((id) => fetch(`/api/events/${id}`, { method: "DELETE" })));
@@ -240,8 +267,48 @@ export default function TimelinePage() {
<span className="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide capitalize">{dayLabel(day)}</span>
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
<span className="text-xs text-slate-400 dark:text-slate-500">{grouped[day].length}</span>
{!selectMode && (
<button
onClick={() => { setDuplicating(day); setDupTarget(""); setDupError(""); }}
title="Dupliquer ce jour"
className="p-1 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition"
>
<Copy className="w-3.5 h-3.5" />
</button>
)}
</div>
{duplicating === day && (
<div className="mb-3 px-4 py-3 bg-indigo-50 dark:bg-indigo-950/40 border border-indigo-200 dark:border-indigo-800 rounded-xl">
<p className="text-xs font-medium text-indigo-700 dark:text-indigo-300 mb-2">
Copier les {grouped[day].length} événements vers
</p>
<div className="flex items-center gap-2">
<input
type="date"
value={dupTarget}
onChange={(e) => { setDupTarget(e.target.value); setDupError(""); }}
min={format(new Date(), "yyyy-MM-dd")}
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-indigo-200 dark:border-indigo-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<button
disabled={!dupTarget || dupLoading}
onClick={() => duplicateDay(day, dupTarget)}
className="px-3 py-1.5 text-xs font-medium bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-300 dark:disabled:bg-indigo-900 text-white rounded-lg transition"
>
{dupLoading ? "…" : "Copier"}
</button>
<button
onClick={() => { setDuplicating(null); setDupError(""); }}
className="px-3 py-1.5 text-xs text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 hover:text-slate-700 transition"
>
Annuler
</button>
</div>
{dupError && <p className="mt-1.5 text-xs text-red-500">{dupError}</p>}
</div>
)}
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{grouped[day].map((ev) => {
const cfg = EVENT_CONFIG[ev.type];
-92
View File
@@ -7,99 +7,7 @@ export async function GET() {
<title>Grow API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; background: #0f172a; }
/* topbar + scheme bar */
.swagger-ui .topbar { background: #1e293b; border-bottom: 1px solid #334155; }
.swagger-ui .topbar .download-url-wrapper { display: none; }
.swagger-ui .scheme-container { background: #1e293b; box-shadow: none; border-bottom: 1px solid #334155; padding: 12px 0; }
/* main wrapper */
.swagger-ui { background: #0f172a; color: #e2e8f0; }
.swagger-ui .wrapper { background: #0f172a; }
/* info block */
.swagger-ui .info .title,
.swagger-ui .info h1, .swagger-ui .info h2, .swagger-ui .info h3,
.swagger-ui .info p, .swagger-ui .info li,
.swagger-ui .info a { color: #e2e8f0; }
.swagger-ui .info .base-url { color: #94a3b8; }
/* operation blocks */
.swagger-ui .opblock { background: #1e293b; border-color: #334155; margin-bottom: 8px; }
.swagger-ui .opblock .opblock-summary { border-color: #334155; }
.swagger-ui .opblock .opblock-summary-description { color: #cbd5e1; }
.swagger-ui .opblock .opblock-summary-path { color: #e2e8f0; }
.swagger-ui .opblock-description-wrapper p,
.swagger-ui .opblock-external-docs-wrapper p,
.swagger-ui .opblock-title_normal p { color: #cbd5e1; }
/* section headers (Parameters / Responses) */
.swagger-ui .opblock-section-header { background: #263348; border-bottom: 1px solid #334155; }
.swagger-ui .opblock-section-header h4,
.swagger-ui .opblock-section-header label { color: #e2e8f0; }
/* tables */
.swagger-ui table thead tr td, .swagger-ui table thead tr th { color: #94a3b8; border-bottom: 1px solid #334155; }
.swagger-ui .parameters-col_description p,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type,
.swagger-ui .parameter__in { color: #cbd5e1; }
.swagger-ui .response-col_status { color: #e2e8f0; }
.swagger-ui .response-col_description p { color: #cbd5e1; }
/* "Try it out" / Execute */
.swagger-ui .btn { color: #e2e8f0; border-color: #475569; background: #1e293b; }
.swagger-ui .btn:hover { background: #263348; }
.swagger-ui .btn.execute { background: #2563eb; border-color: #2563eb; color: #fff; }
.swagger-ui .btn.execute:hover { background: #1d4ed8; }
.swagger-ui .btn.cancel { background: #7f1d1d; border-color: #991b1b; color: #fca5a5; }
/* inputs */
.swagger-ui input[type=text], .swagger-ui textarea, .swagger-ui select {
background: #0f172a; color: #e2e8f0; border: 1px solid #475569;
}
/* models / schemas */
.swagger-ui section.models { background: #0f172a; border: 1px solid #334155; }
.swagger-ui section.models h4 { color: #e2e8f0; }
.swagger-ui .model-box { background: #1e293b; }
.swagger-ui .model { color: #cbd5e1; }
.swagger-ui .model-title { color: #e2e8f0; }
.swagger-ui .prop-type { color: #38bdf8; }
.swagger-ui .prop-format { color: #94a3b8; }
/* response bodies */
.swagger-ui .highlight-code { background: #0f172a; }
.swagger-ui .microlight { background: #0f172a !important; color: #e2e8f0 !important; }
.swagger-ui .responses-inner { background: #1e293b; }
.swagger-ui .response-controls { background: #263348; }
/* auth modal */
.swagger-ui .dialog-ux .modal-ux { background: #1e293b; border: 1px solid #334155; }
.swagger-ui .dialog-ux .modal-ux-header { background: #263348; border-bottom: 1px solid #334155; }
.swagger-ui .dialog-ux .modal-ux-header h3 { color: #e2e8f0; }
.swagger-ui .dialog-ux .modal-ux-content p,
.swagger-ui .dialog-ux .modal-ux-content label { color: #cbd5e1; }
.swagger-ui .auth-container .errors { background: #7f1d1d; color: #fca5a5; }
/* tag sections */
.swagger-ui .opblock-tag { color: #e2e8f0; border-bottom: 1px solid #334155; }
.swagger-ui .opblock-tag:hover { background: #1e293b; }
/* server selector */
.swagger-ui .servers > label select { background: #1e293b; color: #e2e8f0; border-color: #475569; }
/* authorize button */
.swagger-ui .authorization__btn { color: #38bdf8; }
.swagger-ui .btn.authorize { color: #38bdf8; border-color: #38bdf8; background: transparent; }
.swagger-ui .btn.authorize svg { fill: #38bdf8; }
.swagger-ui .btn.authorize.locked { color: #4ade80; border-color: #4ade80; }
.swagger-ui .btn.authorize.locked svg { fill: #4ade80; }
/* misc text */
.swagger-ui .markdown p, .swagger-ui .renderedMarkdown p { color: #cbd5e1; }
.swagger-ui .loading-container .loading::after { color: #94a3b8; }
</style>
</head>
<body>
+3 -1
View File
@@ -10,7 +10,7 @@ export async function PATCH(
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const { name, birthDate, gender } = await req.json();
const { name, birthDate, gender, isBreastfed, hasPump } = await req.json();
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
@@ -25,6 +25,8 @@ export async function PATCH(
...(name ? { name } : {}),
...(birthDate ? { birthDate: new Date(birthDate) } : {}),
...(gender !== undefined ? { gender } : {}),
...(isBreastfed !== undefined ? { isBreastfed } : {}),
...(hasPump !== undefined ? { hasPump } : {}),
},
});
+3
View File
@@ -27,6 +27,9 @@ export async function POST(req: Request) {
const { name, birthDate, gender } = await req.json();
if (!name || !birthDate) return NextResponse.json({ error: "name et birthDate requis" }, { status: 400 });
if (isNaN(new Date(birthDate).getTime())) return NextResponse.json({ error: "Date invalide" }, { status: 400 });
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user?.familyId) return NextResponse.json({ error: "Aucune famille" }, { status: 400 });
+11
View File
@@ -12,6 +12,11 @@ export async function PATCH(
const { id } = await params;
const body = await req.json();
const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
if (!user?.familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const owned = await prisma.eventTemplate.findFirst({ where: { id, familyId: user.familyId } });
if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
const template = await prisma.eventTemplate.update({
where: { id },
data: {
@@ -33,6 +38,12 @@ export async function DELETE(
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const delUser = await prisma.user.findUnique({ where: { id: session.user.id }, select: { familyId: true } });
if (!delUser?.familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const delOwned = await prisma.eventTemplate.findFirst({ where: { id, familyId: delUser.familyId } });
if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
await prisma.eventTemplate.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
+65
View File
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userId = session.user.id;
const { babyId, sourceDate, targetDate } = await req.json();
if (!babyId || !sourceDate || !targetDate) {
return NextResponse.json({ error: "babyId, sourceDate et targetDate requis" }, { status: 400 });
}
if (isNaN(new Date(sourceDate).getTime()) || isNaN(new Date(targetDate).getTime())) {
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
}
if (sourceDate === targetDate) {
return NextResponse.json({ error: "Les dates source et cible doivent être différentes" }, { status: 400 });
}
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
const dayStart = new Date(sourceDate);
dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(sourceDate);
dayEnd.setHours(23, 59, 59, 999);
const source = await prisma.event.findMany({
where: { babyId, startedAt: { gte: dayStart, lte: dayEnd } },
orderBy: { startedAt: "asc" },
});
if (source.length === 0) {
return NextResponse.json({ error: "Aucun événement ce jour-là" }, { status: 404 });
}
// Compute day offset in ms between sourceDate and targetDate
const srcMidnight = new Date(sourceDate);
srcMidnight.setHours(0, 0, 0, 0);
const tgtMidnight = new Date(targetDate);
tgtMidnight.setHours(0, 0, 0, 0);
const offsetMs = tgtMidnight.getTime() - srcMidnight.getTime();
const created = await prisma.$transaction(
source.map((ev) =>
prisma.event.create({
data: {
babyId,
userId,
type: ev.type,
startedAt: new Date(ev.startedAt.getTime() + offsetMs),
endedAt: ev.endedAt ? new Date(ev.endedAt.getTime() + offsetMs) : null,
notes: ev.notes,
metadata: ev.metadata ?? undefined,
},
})
)
);
return NextResponse.json({ count: created.length, events: created }, { status: 201 });
}
+6 -2
View File
@@ -3,6 +3,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { logAudit } from "@/lib/audit";
import { dispatchWebhook } from "@/lib/webhooks";
import { ALL_EVENT_TYPES } from "@/lib/event-config";
export async function GET(req: Request) {
const session = await auth();
@@ -10,9 +11,12 @@ export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const babyId = searchParams.get("babyId");
const limit = parseInt(searchParams.get("limit") ?? "50");
const offset = parseInt(searchParams.get("offset") ?? "0");
const limit = Math.max(1, Math.min(parseInt(searchParams.get("limit") ?? "50") || 50, 500));
const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0") || 0);
const type = searchParams.get("type");
if (type && !ALL_EVENT_TYPES.includes(type as never)) {
return NextResponse.json({ error: "Type invalide" }, { status: 400 });
}
const dateFrom = searchParams.get("dateFrom");
const dateTo = searchParams.get("dateTo");
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 });
}
const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? "";
const origin = (process.env.NEXTAUTH_URL ?? "").replace(/\/$/, "");
const assignedRole = role as Role;
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000);
+4
View File
@@ -17,6 +17,10 @@ export async function GET(req: Request) {
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
if (date && isNaN(new Date(date).getTime())) {
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
}
const where: Record<string, unknown> = { babyId };
if (date) {
@@ -12,6 +12,11 @@ export async function PATCH(
const { id } = await params;
const body = await req.json();
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const owned = await prisma.medicationProfile.findFirst({ where: { id, familyId } });
if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
const profile = await prisma.medicationProfile.update({
where: { id },
data: {
@@ -35,6 +40,12 @@ export async function DELETE(
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const { id } = await params;
const delFamilyId = (session.user as { familyId?: string }).familyId;
if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const delOwned = await prisma.medicationProfile.findFirst({ where: { id, familyId: delFamilyId } });
if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 });
await prisma.medicationProfile.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
@@ -12,6 +12,11 @@ export async function GET(req: Request) {
const babyId = searchParams.get("babyId");
if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 });
const familyId = (session.user as { familyId?: string }).familyId;
if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } });
if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
// Fetch last 100 medication events — enough to find latest per med
const events = await prisma.event.findMany({
where: { babyId, type: "MEDICATION" },
+10 -2
View File
@@ -35,7 +35,7 @@ export async function POST(req: Request) {
if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const body = await req.json();
const { babyId, volume, storedAt, location, notes, expiryHours } = body;
const { babyId, volume, storedAt, location, notes, expiryHours, expiresAt: expiresAtOverride } = body;
if (!babyId || !volume) {
return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 });
@@ -46,10 +46,18 @@ export async function POST(req: Request) {
const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } });
if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
if (storedAt && isNaN(new Date(storedAt).getTime())) {
return NextResponse.json({ error: "Date invalide" }, { status: 400 });
}
const loc = location ?? "fridge";
const storedDate = storedAt ? new Date(storedAt) : new Date();
const expiresAt = expiresAtOverride
? new Date(expiresAtOverride)
: (() => {
const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge;
const expiresAt = new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
return new Date(storedDate.getTime() + hours * 60 * 60 * 1000);
})();
const stock = await prisma.milkStock.create({
data: {
+5
View File
@@ -16,6 +16,11 @@ export async function POST(req: Request) {
});
if (!baby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
const sessionFamilyId = (session.user as { familyId?: string }).familyId;
if (!sessionFamilyId || baby.familyId !== sessionFamilyId) {
return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 });
}
const lastFeed = await prisma.event.findFirst({
where: { babyId, type: { in: ["BREASTFEED", "BOTTLE"] } },
orderBy: { startedAt: "desc" },
+1
View File
@@ -26,6 +26,7 @@ export async function PATCH(
...(body.intervalHours !== undefined ? { intervalHours: parseFloat(body.intervalHours) } : {}),
...(body.startAt !== undefined ? { startAt: new Date(body.startAt) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
...(body.lastSentAt !== undefined ? { lastSentAt: body.lastSentAt ? new Date(body.lastSentAt) : null } : {}),
},
});
+2 -2
View File
@@ -5,7 +5,7 @@ import { join } from "path";
import { randomUUID } from "crypto";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"];
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const MAX_SIZE = 30 * 1024 * 1024; // 30 MB
// Configurable via env so Docker volume can be mounted at /data/uploads
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads");
@@ -19,7 +19,7 @@ export async function POST(req: Request) {
if (!file) return NextResponse.json({ error: "Fichier manquant" }, { status: 400 });
if (!ALLOWED_TYPES.includes(file.type)) return NextResponse.json({ error: "Type de fichier non supporté" }, { status: 400 });
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 5 MB)" }, { status: 400 });
if (file.size > MAX_SIZE) return NextResponse.json({ error: "Fichier trop volumineux (max 30 MB)" }, { status: 400 });
const ext = file.type.split("/")[1].replace("jpeg", "jpg");
const filename = `${randomUUID()}.${ext}`;
+2 -2
View File
@@ -18,8 +18,8 @@ export async function GET(req: Request) {
const type = searchParams.get("type");
const from = searchParams.get("from");
const to = searchParams.get("to");
const limit = Math.min(parseInt(searchParams.get("limit") ?? "50"), 500);
const offset = parseInt(searchParams.get("offset") ?? "0");
const limit = Math.max(1, Math.min(parseInt(searchParams.get("limit") ?? "50") || 50, 500));
const offset = Math.max(0, parseInt(searchParams.get("offset") ?? "0") || 0);
if (!babyId) return NextResponse.json({ error: "babyId is required" }, { status: 400 });
+1
View File
@@ -33,6 +33,7 @@ export async function POST(req: Request) {
const { babyId, date, weight, height, headCirc, notes } = body;
if (!babyId || !date) return NextResponse.json({ error: "babyId and date are required" }, { status: 400 });
if (isNaN(new Date(date).getTime())) return NextResponse.json({ error: "Invalid date format" }, { status: 400 });
const baby = await prisma.baby.findFirst({ where: { id: babyId, familyId: ctx.familyId } });
if (!baby) return NextResponse.json({ error: "Baby not found" }, { status: 404 });
+1 -1
View File
@@ -36,7 +36,7 @@ export async function GET(req: Request) {
return NextResponse.json({
date: dayStart.toISOString().slice(0, 10),
counts,
feeds: counts.BREASTFEED ?? 0 + (counts.BOTTLE ?? 0),
feeds: (counts.BREASTFEED ?? 0) + (counts.BOTTLE ?? 0),
diapers: (counts.DIAPER_WET ?? 0) + (counts.DIAPER_STOOL ?? 0),
sleepMinutes: Math.round(sleepMs / 60000),
lastFeedAt: lastFeed ? lastFeed.startedAt : null,
+12 -1
View File
@@ -3,6 +3,17 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateWebhookSecret } from "@/lib/webhooks";
const PRIVATE_IP_RE =
/^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
function isSafeUrl(raw: string): boolean {
try {
const { protocol, hostname } = new URL(raw);
if (protocol !== "https:" && protocol !== "http:") return false;
return !PRIVATE_IP_RE.test(hostname);
} catch { return false; }
}
export async function GET() {
const session = await auth();
const familyId = (session?.user as { familyId?: string } | undefined)?.familyId;
@@ -25,7 +36,7 @@ export async function POST(req: Request) {
const { url, events } = await req.json();
if (!url || !events?.length) return NextResponse.json({ error: "url et events requis" }, { status: 400 });
try { new URL(url); } catch { return NextResponse.json({ error: "URL invalide" }, { status: 400 }); }
if (!isSafeUrl(url)) return NextResponse.json({ error: "URL invalide ou non autorisée" }, { status: 400 });
const secret = generateWebhookSecret();
+2
View File
@@ -12,6 +12,7 @@ import {
Footprints,
Baby,
Activity,
FileText,
LucideProps,
} from "lucide-react";
@@ -29,6 +30,7 @@ const ICONS: Record<string, React.FC<LucideProps>> = {
Footprints,
Baby,
Activity,
FileText,
};
interface EventIconProps extends LucideProps {
+101 -10
View File
@@ -33,6 +33,7 @@ interface FormData {
pumpVolume?: string;
stoolColor?: string;
stoolAmount?: "small" | "medium" | "large";
leaked?: boolean;
medName?: string;
medDose?: string;
medUnit?: string;
@@ -69,12 +70,12 @@ interface MilkStock {
}
const STOOL_COLORS = [
{ value: "jaune", label: "Jaune", bg: "#fef3c7", dot: "#f59e0b" },
{ value: "vert", label: "Vert", bg: "#d1fae5", dot: "#10b981" },
{ value: "marron", label: "Marron", bg: "#fef3c7", dot: "#92400e" },
{ value: "orange", label: "Orange", bg: "#ffedd5", dot: "#f97316" },
{ value: "noir", label: "Méconium", bg: "#f1f5f9", dot: "#1e293b" },
{ value: "rouge", label: "Rouge", bg: "#fee2e2", dot: "#ef4444" },
{ value: "jaune", label: "Jaune", bg: "#fef3c7", darkBg: "#451a03", dot: "#f59e0b" },
{ value: "vert", label: "Vert", bg: "#d1fae5", darkBg: "#022c22", dot: "#10b981" },
{ value: "marron", label: "Marron", bg: "#fef3c7", darkBg: "#1c0a00", dot: "#92400e" },
{ value: "orange", label: "Orange", bg: "#ffedd5", darkBg: "#431407", dot: "#f97316" },
{ value: "noir", label: "Méconium", bg: "#f1f5f9", darkBg: "#0f172a", dot: "#1e293b" },
{ value: "rouge", label: "Rouge", bg: "#fee2e2", darkBg: "#450a0a", dot: "#ef4444" },
];
function localNowString() {
@@ -112,6 +113,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
volume: meta.volume != null && type === "BOTTLE" ? String(meta.volume) : undefined,
stoolColor: (meta.color as string) ?? "jaune",
stoolAmount: (meta.amount as "small" | "medium" | "large") ?? "medium",
leaked: (meta.leaked as boolean) ?? false,
medName: (meta.name as string) ?? undefined,
medDose: (meta.dose as string) ?? undefined,
medUnit: (meta.unit as string) ?? undefined,
@@ -139,6 +141,17 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
// Milk stock state (BOTTLE type)
const [milkStocks, setMilkStocks] = useState<MilkStock[]>([]);
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const check = () => setIsDark(document.documentElement.classList.contains("dark"));
check();
const obs = new MutationObserver(check);
obs.observe(document.documentElement, { attributeFilter: ["class"] });
return () => obs.disconnect();
}, []);
// Combined diaper toggle
const [diaperBoth, setDiaperBoth] = useState(false);
// Pump → stock state
const [addToStock, setAddToStock] = useState(false);
@@ -246,8 +259,8 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
alert("Image trop volumineuse (max 5 Mo)");
if (file.size > 30 * 1024 * 1024) {
alert("Image trop volumineuse (max 30 Mo)");
return;
}
setPhotoUploading(true);
@@ -306,6 +319,7 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
if (type === "BOTTLE") { metadata.volume = form.volume ? parseFloat(form.volume) : null; metadata.bottleType = form.bottleType; }
if (type === "PUMP") { metadata.side = form.pumpSide; metadata.volume = form.pumpVolume ? parseFloat(form.pumpVolume) : null; }
if (type === "DIAPER_STOOL") { metadata.color = form.stoolColor; metadata.amount = form.stoolAmount; }
if (type === "DIAPER_WET" || type === "DIAPER_STOOL") { if (form.leaked) metadata.leaked = true; }
if (type === "MEDICATION") {
metadata.name = form.medName;
metadata.dose = form.medDose;
@@ -358,6 +372,24 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
metadata: Object.keys(metadata).length > 0 ? metadata : null,
}),
});
if (diaperBoth && (type === "DIAPER_WET" || type === "DIAPER_STOOL")) {
const secondType = type === "DIAPER_WET" ? "DIAPER_STOOL" : "DIAPER_WET";
const secondMeta = type === "DIAPER_WET"
? { color: form.stoolColor, amount: form.stoolAmount, ...(form.leaked ? { leaked: true } : {}) }
: form.leaked ? { leaked: true } : null;
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
babyId,
type: secondType,
startedAt: toUTC(form.startedAt),
endedAt: null,
notes: null,
metadata: secondMeta,
}),
});
}
}
if (type === "BOTTLE" && selectedStockId && form.bottleType === "maternal") {
await fetch(`/api/milk/${selectedStockId}`, {
@@ -612,11 +644,11 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur</label>
<div className="grid grid-cols-3 gap-2">
{STOOL_COLORS.map(({ value, label, bg, dot }) => (
{STOOL_COLORS.map(({ value, label, bg, darkBg, dot }) => (
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium transition ${
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
}`} style={{ background: bg }}>
}`} style={{ background: isDark ? darkBg : bg }}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
{label}
</button>
@@ -637,6 +669,65 @@ export function EventModal({ type, babyId, onClose, onSaved, initialEvent }: Pro
</div>
)}
{/* Leaked diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && (
<button
type="button"
onClick={() => setForm((f) => ({ ...f, leaked: !f.leaked }))}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>Débordement</span>
<div className={`w-9 h-5 rounded-full transition-colors ${form.leaked ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${form.leaked ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
)}
{/* Combined diaper toggle */}
{(type === "DIAPER_WET" || type === "DIAPER_STOOL") && !isEditing && (
<div className="space-y-3">
<button
type="button"
onClick={() => setDiaperBoth((v) => !v)}
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl border border-slate-200 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition"
>
<span>{type === "DIAPER_WET" ? "Aussi des selles" : "Aussi mouillée"}</span>
<div className={`w-9 h-5 rounded-full transition-colors ${diaperBoth ? "bg-indigo-600" : "bg-slate-200 dark:bg-slate-600"} relative flex-shrink-0`}>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${diaperBoth ? "translate-x-4" : "translate-x-0.5"}`} />
</div>
</button>
{diaperBoth && type === "DIAPER_WET" && (
<div className="space-y-3 pl-1">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Couleur des selles</label>
<div className="grid grid-cols-3 gap-2">
{STOOL_COLORS.map(({ value, label, bg, darkBg, dot }) => (
<button key={value} type="button" onClick={() => setForm((f) => ({ ...f, stoolColor: value }))}
className={`flex items-center gap-2 px-2.5 py-2 rounded-lg border text-xs font-medium text-slate-800 transition ${
form.stoolColor === value ? "border-indigo-500 ring-1 ring-indigo-300" : "border-slate-200 dark:border-slate-600 hover:border-slate-300"
}`} style={{ background: isDark ? darkBg : bg }}>
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ background: dot }} />
{label}
</button>
))}
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-300 mb-1.5">Quantité</label>
<div className="flex gap-2">
{(["small", "medium", "large"] as const).map((amt) => (
<button key={amt} type="button" onClick={() => setForm((f) => ({ ...f, stoolAmount: amt }))}
className={`${segmentBase} ${form.stoolAmount === amt ? segmentActive : segmentInactive}`}>
{amt === "small" ? "Peu" : amt === "medium" ? "Moyen" : "Beaucoup"}
</button>
))}
</div>
</div>
</div>
)}
</div>
)}
{/* Medication — profile picker */}
{type === "MEDICATION" && (
<div className="space-y-3">
+3
View File
@@ -186,6 +186,7 @@ export function SidebarNav() {
{/* Grouped links */}
{DRAWER_GROUPS.map((group) => {
const groupLinks = group.hrefs
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
.map((href) => LINKS.find((l) => l.href === href))
.filter(Boolean) as typeof LINKS;
if (groupLinks.length === 0) return null;
@@ -265,6 +266,7 @@ export function BottomNav() {
const { data: session } = useSession();
const isSuperAdmin = (session?.user as { isSuperAdmin?: boolean })?.isSuperAdmin;
const [drawerOpen, setDrawerOpen] = useState(false);
const { selectedBaby } = useBaby();
const secondaryLinks = isSuperAdmin
? [...SECONDARY_LINKS, { href: "/admin", label: "Admin", Icon: ShieldCheck }]
@@ -327,6 +329,7 @@ export function BottomNav() {
<div className="px-4 pb-4 space-y-4">
{DRAWER_GROUPS.map((group) => {
const groupLinks = group.hrefs
.filter((href) => !(href === "/milk" && !selectedBaby?.hasPump))
.map((href) => secondaryLinks.find((l) => l.href === href))
.filter(Boolean) as typeof secondaryLinks;
if (groupLinks.length === 0) return null;
+14 -3
View File
@@ -11,7 +11,7 @@ import { Plus, X } from "lucide-react";
const FAB_HIDDEN_PATHS = ["/settings", "/admin"];
const QUICK_TYPES: EventType[] = [
const ALL_QUICK_TYPES: EventType[] = [
"BREASTFEED",
"BOTTLE",
"PUMP",
@@ -21,12 +21,23 @@ const QUICK_TYPES: EventType[] = [
"NIGHT_SLEEP",
"MEDICATION",
"TEMPERATURE",
"BATH",
"WALK",
"TUMMY_TIME",
"SYMPTOM",
"OTHER",
];
export function QuickAddFab() {
const qc = useQueryClient();
const { selectedBaby } = useBaby();
const pathname = usePathname();
const quickTypes = ALL_QUICK_TYPES.filter((t) => {
if (t === "BREASTFEED" && !selectedBaby?.isBreastfed) return false;
if (t === "PUMP" && !selectedBaby?.hasPump) return false;
return true;
});
const [sheetOpen, setSheetOpen] = useState(false);
const [activeModal, setActiveModal] = useState<EventType | null>(null);
@@ -69,8 +80,8 @@ export function QuickAddFab() {
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-4 gap-2 px-4 pb-2">
{QUICK_TYPES.map((type) => {
<div className="grid grid-cols-5 gap-2 px-4 pb-2">
{quickTypes.map((type) => {
const cfg = EVENT_CONFIG[type];
return (
<button
+2
View File
@@ -8,6 +8,8 @@ interface Baby {
name: string;
birthDate: string;
gender?: string;
isBreastfed: boolean;
hasPump: boolean;
}
interface Family {
+12 -1
View File
@@ -11,7 +11,8 @@ export type EventType =
| "BATH"
| "WALK"
| "TUMMY_TIME"
| "SYMPTOM";
| "SYMPTOM"
| "OTHER";
export const EVENT_CONFIG: Record<
EventType,
@@ -164,6 +165,16 @@ export const EVENT_CONFIG: Record<
hasDuration: false,
hasTimer: false,
},
OTHER: {
label: "Autre",
icon: "FileText",
color: "#64748b",
colorClass: "text-slate-500",
bgClass: "bg-slate-100 dark:bg-slate-800",
borderClass: "border-slate-200 dark:border-slate-700",
hasDuration: false,
hasTimer: false,
},
};
export const ALL_EVENT_TYPES = Object.keys(EVENT_CONFIG) as EventType[];
+15
View File
@@ -1,6 +1,20 @@
import { createHmac, randomBytes } from "crypto";
import { prisma } from "./prisma";
const PRIVATE_IP_RE =
/^(localhost|127\.|0\.0\.0\.0|::1|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/i;
function isSafeWebhookUrl(raw: string): boolean {
try {
const { protocol, hostname } = new URL(raw);
if (protocol !== "https:" && protocol !== "http:") return false;
if (PRIVATE_IP_RE.test(hostname)) return false;
return true;
} catch {
return false;
}
}
export type WebhookEventType =
| "event.created"
| "growth.created"
@@ -26,6 +40,7 @@ export async function dispatchWebhook(
await Promise.allSettled(
hooks.map(async (hook) => {
if (!isSafeWebhookUrl(hook.url)) return;
const sig = createHmac("sha256", hook.secret).update(body).digest("hex");
await fetch(hook.url, {
method: "POST",