From 8f68eebeb073782589889d74f609978f0c5f217a Mon Sep 17 00:00:00 2001 From: Arnaud Nelissen Date: Mon, 15 Jun 2026 20:44:45 +0200 Subject: [PATCH] feat: UI/UX improvements pass + fix photo 404 in Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Photos: - Serve uploads via /api/photos/[filename] from UPLOAD_DIR env var - Default UPLOAD_DIR=/data/uploads (mount as Docker volume for persistence) - Upload route creates dir on demand, no root-permission issue - Dockerfile creates /data/uploads owned by nextjs user Growth: - Show chart with single measurement (was hidden until 2+ points) - PC label expanded to "PC — Périmètre crânien (cm)" - WHO percentile ⓘ tooltip explaining P50/P3-P97 - Date field defaults to today in add-measurement form Stats: - Heatmap legend shows actual counts (0 1 2 3 4+) not just Moins/Plus - Sleep timeline labeled "Fenêtre 19h – 11h" Timeline: - Filter type persisted in URL param (?type=...) — survives navigation - Inline note save shows ✓ flash after blur - Photo thumbnails open in new tab Photos: - Lightbox wraps: next on last → first, prev on first → last Search: - Result count header "X événements · Y notes" - Event results link to /timeline?date=...&type=... - Note results link to /notes?date=... Notes: - Unsaved indicator (amber dot) while textarea dirty, green ✓ after save Milestones: - Sort by date ascending within each month group Settings: - Pushover key field has eye toggle (show/hide) - API key delete shows confirmation with key name - All sections wrapped in collapsible SectionCard accordion Dashboard: - Empty state callout for new users with no events today Event modal: - Milk lot selection shows "Sélectionnez un lot — il sera marqué comme utilisé" - Photo upload validates 5MB client-side before sending - Timer resume shows "Reprise du chronomètre" when loaded from localStorage Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 4 ++ IMPROVEMENTS.md | 76 ++++++++++++++++++++++++++ src/app/(app)/dashboard/page.tsx | 11 ++++ src/app/(app)/growth/page.tsx | 18 +++--- src/app/(app)/milestones/page.tsx | 3 + src/app/(app)/notes/page.tsx | 10 +++- src/app/(app)/photos/page.tsx | 10 ++-- src/app/(app)/search/page.tsx | 15 +++-- src/app/(app)/settings/page.tsx | 65 +++++++++++++++------- src/app/(app)/stats/page.tsx | 26 +++++---- src/app/(app)/timeline/page.tsx | 18 ++++-- src/app/api/photos/[filename]/route.ts | 37 +++++++++++++ src/app/api/upload/route.ts | 11 ++-- src/components/event-modal.tsx | 14 ++++- 14 files changed, 258 insertions(+), 60 deletions(-) create mode 100644 IMPROVEMENTS.md create mode 100644 src/app/api/photos/[filename]/route.ts diff --git a/Dockerfile b/Dockerfile index fcd0375..502e520 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,10 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated COPY --from=builder /app/prisma ./prisma +# Upload directory — mount a volume here for persistence: -v /host/uploads:/data/uploads +RUN mkdir -p /data/uploads && chown nextjs:nodejs /data/uploads +ENV UPLOAD_DIR=/data/uploads + USER nextjs EXPOSE 3000 diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md new file mode 100644 index 0000000..bbd0ef7 --- /dev/null +++ b/IMPROVEMENTS.md @@ -0,0 +1,76 @@ +# Improvements & Known Issues + +## Critical Bugs + +- [x] **Photos 404 in Docker** — Upload wrote to `public/uploads/` (root-owned in standalone container). Fix: serve via `/api/photos/[filename]` from configurable `UPLOAD_DIR=/data/uploads`. Mount `/data/uploads` as Docker volume. +- [x] **WHO curves invisible when baby has measurements** — `connectNulls` missing on percentile Lines; baby data rows with no p-values broke line segments. +- [x] **Symptom icon missing** — `Activity` not registered in `event-icon.tsx` icon map. +- [x] **OMS curves break when measurements exist** — same as above (connectNulls). + +## UI/UX Improvements + +### Dashboard +- [ ] **Empty state for new users** — show "Commencez par enregistrer un repas →" callout when no events today +- [ ] **Active timers show which baby** — add baby name if multiple babies in family + +### Growth +- [ ] **Single-measurement chart** — currently blank; should show point + WHO curves + message +- [ ] **"PC" label** — expand to "Périmètre crânien (PC)" on first use +- [ ] **WHO percentile tooltip** — "P50 = médiane : 50% des bébés sont en dessous" +- [ ] **Date field defaults to today** in add-measurement form +- [ ] **PDF export percentile rank** — show baby's WHO band in PDF table + +### Stats +- [ ] **Heatmap legend** — show actual count numbers (0, 1, 2, 3+) not just "Moins/Plus" +- [ ] **Sleep timeline** — label "19h → 11h" window, make configurable +- [ ] **Chart zero-data gaps** — distinguish "no data" vs "zero events" visually + +### Timeline +- [ ] **Filter state sticky** — persist in URL param (`?type=BREASTFEED`) +- [ ] **Inline note save feedback** — subtle "Enregistré ✓" flash on blur +- [ ] **Photo in timeline** — make thumbnail clickable to open lightbox +- [ ] **Select mode indicator** — show "3 sélectionnés · Supprimer" counter badge + +### Photos +- [ ] **Square thumbnails** — force aspect-ratio: 1/1 with object-cover +- [ ] **Lightbox wraps** — next on last → first photo; prev on first → last +- [ ] **Delete from lightbox** — trash icon in lightbox → removes photo from event metadata + +### Search +- [ ] **Results clickable** — link events to `/timeline?date=YYYY-MM-DD`, notes to `/notes?date=...` +- [ ] **Result count header** — "X événements · Y notes" +- [ ] **Date/type filter chips** on search results + +### Notes +- [ ] **Unsaved indicator** — show "Non enregistré" dot while textarea is dirty +- [ ] **Date jump input** — calendar picker to jump to any date (not just nav arrows) + +### Milestones +- [ ] **Sort by date** within month, not creation order + +### Settings +- [ ] **Pushover key eye toggle** — show/hide pasted key (copy pattern from API keys) +- [ ] **API key delete confirmation** — "This will break integrations using this key" +- [ ] **Accordion/tabs** — collapse long settings sections on mobile +- [ ] **Invite link TTL** — show expiry date next to copy-link button + +### Event Modal +- [ ] **Timer resume indicator** — show "Reprise depuis 12:34" when re-opening active timer +- [ ] **Milk lot selection hint** — "Sélectionnez un lot qui sera marqué comme utilisé" +- [ ] **Photo size feedback** — client-side 5MB validation with error message + +### Navigation +- [ ] **Baby switcher accessibility** — add `aria-label="Sélectionner bébé"` to select +- [ ] **Unsaved state warning** — prompt before navigating away from dirty notes + +## Feature Backlog + +- [ ] **Vaccination tracker** — dates, vaccine name, lot, next-due reminder + push alert +- [ ] **Doctor visit log** — notes, prescriptions, next appointment date +- [ ] **Teeth tracker** — visual mouth diagram, mark appearance date +- [ ] **Baby schedule prediction** — "usually naps around 14h30" from historical patterns +- [ ] **Growth chart in PDF** — embed chart image (jsPDF canvas) not just table +- [ ] **Natural language digest** — "Emma feeds every 2h45 on average" weekly push +- [ ] **Wellness check-in** — parent self-reports sleep/mood for postpartum monitoring +- [ ] **Weight gain on-track indicator** — vs last weigh-in projected line on growth chart +- [ ] **Contraction timer** — repurpose timer infra for pre-birth use diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 783777a..c1e8617 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -600,6 +600,17 @@ export default function DashboardPage() { })} + {/* Empty state for new users */} + {todayEvents.length === 0 && events.filter((e) => !e.endedAt && EVENT_CONFIG[e.type]?.hasTimer).length === 0 && ( +
+ 👶 +
+

Bienvenue sur Grow !

+

Commencez par enregistrer un repas, une couche ou un sommeil via les boutons ci-dessous.

+
+
+ )} + {/* Quick log */}
{QUICK_LOG_GROUPS.map((group) => ( diff --git a/src/app/(app)/growth/page.tsx b/src/app/(app)/growth/page.tsx index 4511f53..e687e03 100644 --- a/src/app/(app)/growth/page.tsx +++ b/src/app/(app)/growth/page.tsx @@ -159,7 +159,7 @@ export default function GrowthPage() { function openAddForm() { const last = logs.at(-1); setForm({ - date: "", + date: format(new Date(), "yyyy-MM-dd"), weight: last?.weight ? (last.weight / 1000).toFixed(3) : "", height: last?.height ? String(last.height) : "", headCirc: last?.headCirc ? String(last.headCirc) : "", @@ -343,7 +343,7 @@ export default function GrowthPage() { step="0.1" min="30" max="120" placeholder="50" className={inputCls} />
- + setForm((f) => ({ ...f, headCirc: e.target.value }))} step="0.1" min="20" max="60" placeholder="34" className={inputCls} />
@@ -377,7 +377,7 @@ export default function GrowthPage() { )} {latest.headCirc && (
-

Périmètre crânien

+

Périmètre crânien (PC)

{latest.headCirc} cm

)} @@ -405,13 +405,17 @@ export default function GrowthPage() {
{/* Weight + WHO */} - {weightData.length > 1 && ( + {weightData.length > 0 && (

Poids (kg)

Bébé - OMS P3–P97 + + + OMS P3–P97 + +
@@ -431,7 +435,7 @@ export default function GrowthPage() {
{/* Height */} - {heightData.length > 1 && ( + {heightData.length > 0 && (

Taille (cm)

@@ -447,7 +451,7 @@ export default function GrowthPage() { )} {/* Head circumference */} - {headData.length > 1 && ( + {headData.length > 0 && (

Périmètre crânien (cm)

diff --git a/src/app/(app)/milestones/page.tsx b/src/app/(app)/milestones/page.tsx index 67f075f..d7ebfc6 100644 --- a/src/app/(app)/milestones/page.tsx +++ b/src/app/(app)/milestones/page.tsx @@ -82,6 +82,9 @@ function groupByMonth(milestones: Milestone[]) { } groups[seen[key]].items.push(m); } + for (const g of groups) { + g.items.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + } return groups; } diff --git a/src/app/(app)/notes/page.tsx b/src/app/(app)/notes/page.tsx index ba311b0..fdb73cb 100644 --- a/src/app/(app)/notes/page.tsx +++ b/src/app/(app)/notes/page.tsx @@ -47,6 +47,7 @@ export default function NotesPage() { const [currentDate, setCurrentDate] = useState(startOfDay(new Date())); const [draftContent, setDraftContent] = useState(""); + const [dirty, setDirty] = useState(false); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(null); const [isDeleting, setIsDeleting] = useState(false); @@ -99,6 +100,7 @@ export default function NotesPage() { if (isDayLoading) return; setDraftContent(currentNote?.content ?? ""); setSaveError(null); + setDirty(false); }, [currentNote?.id, dateStr, isDayLoading]); function goToPrevDay() { @@ -141,6 +143,7 @@ export default function NotesPage() { } qc.invalidateQueries({ queryKey: ["journal", babyId, dateStr] }); qc.invalidateQueries({ queryKey: ["journal-recent", babyId] }); + setDirty(false); } catch (e) { setSaveError(e instanceof Error ? e.message : "Erreur inconnue"); } finally { @@ -266,7 +269,7 @@ export default function NotesPage() {