feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker

This commit is contained in:
Moritz Graf
2026-10-17 07:21:49 +02:00
parent ef50d87bd7
commit 5b8432caf4
96 changed files with 16409 additions and 29 deletions
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md — Mila Tracking App
## Development
```bash
cd projects/mila
npm install
npm run dev
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN mkdir -p /usr/share/nginx/html/tracking && ln -s /usr/share/nginx/html /usr/share/nginx/html/tracking/mila
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
# Mila Tracking App
Mobile-first PWA dashboard for baby sleep, feeding, diaper changes, and WHO growth milestones.
Built with Vue 3 (Composition API), Vite, TypeScript, and Tailwind CSS.
## Features
- **Live State & Timers**: Active sleep duration or wake window updated in real time.
- **Sleep & Routine Tracking**: Today's naps and night sleep with running indicators.
- **Nutritional Logging**: Bottles (ml, Pre/Muttermilch) and solids (Brei g, kcal).
- **Diapers**: Instant 1-tap logging for wet and dirty changes.
- **WHO Growth Standards**: Automatic LMS percentile and z-score calculations (Girls 0-24 Months).
- **Git Sync Awareness**: Real-time warning banner if git background sync reports merge conflicts.
- **Backend**: Backed by FastAPI DuckDB backend at `/duckdb-api/v1`.
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de" class="dark overflow-x-hidden w-full max-w-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Mila" />
<meta name="application-name" content="Mila" />
<meta name="theme-color" content="#090A0F" />
<meta name="msapplication-TileColor" content="#090A0F" />
<!-- Manifest with credentials for forward-auth SSO -->
<link rel="manifest" href="/tracking/mila/mila.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/tracking/mila/mila-icon-512-v3.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/tracking/mila/mila-icon-32-v3.png" />
<link rel="icon" type="image/svg+xml" href="/tracking/mila/mila-icon.svg" />
<link rel="shortcut icon" href="/tracking/mila/favicon.ico" />
<!-- Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-20 overflow-x-hidden w-full max-w-full">
<div id="app" class="w-full max-w-full overflow-x-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
absolute_redirect off;
port_in_redirect off;
root /usr/share/nginx/html;
index index.html;
include /etc/nginx/mime.types;
types {
application/manifest+json webmanifest;
}
location = / {
return 301 /tracking/mila/;
}
location = /tracking/mila {
return 301 /tracking/mila/;
}
location /tracking/mila/ {
try_files $uri $uri/ /tracking/mila/index.html /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
# Service worker
location ~* /sw\.js$ {
default_type application/javascript;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Service-Worker-Allowed "/tracking/mila/";
}
# Webmanifest and manifest.json
location ~* /(?:mila\.webmanifest|manifest\.webmanifest|manifest\.json)$ {
default_type application/manifest+json;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Static assets cache
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+2251
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mila-tracking",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 219 KiB

File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+51
View File
@@ -0,0 +1,51 @@
// Minimal service worker for Mila PWA with instant cache-bypassing navigation
const CACHE_NAME = 'mila-pwa-v5';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
});
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
import type { MilaDashboardData, DayDetailData } from './types'
const API_BASE = '/duckdb-api/v1'
export function getLocalDateString(): string {
const d = new Date()
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export async function fetchMilaDashboard(asOf?: string): Promise<MilaDashboardData> {
const url = asOf ? `${API_BASE}/mila/dashboard?as_of=${encodeURIComponent(asOf)}` : `${API_BASE}/mila/dashboard`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to fetch dashboard: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function fetchMilaDayDetail(date: string): Promise<DayDetailData> {
const res = await fetch(`${API_BASE}/mila/day/${encodeURIComponent(date)}`)
if (!res.ok) {
throw new Error(`Failed to fetch day detail for ${date}: ${res.statusText}`)
}
return res.json()
}
export async function logEvent(payload: {
kind: string
subject: string
payload: Record<string, any>
start?: string
end?: string
date?: string
since?: string
note?: string
actor?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to log event')
}
return res.json()
}
export async function stopSleepEvent(payload: {
subject: string
end_time?: string
since?: string
event_id?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'sleep.session',
...payload,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to stop sleep event')
}
return res.json()
}
export async function deleteEvent(eventId: string): Promise<any> {
const res = await fetch(`${API_BASE}/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to delete event')
}
return res.json()
}
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HistoryRow, MilaDashboardData } from '../types'
import { Utensils, Milk, Apple, Flame, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
dashboard: MilaDashboardData
}>()
// Latest 14 days reversed (chronological left-to-right)
const days = computed(() => {
return [...props.history.slice(0, 14)].reverse()
})
// Today's summary
const todayFeed = computed(() => {
return props.dashboard.today.feeding_summary
})
// 7-day averages
const avgBottleMl = computed(() => props.dashboard.averages.avg_7_feeding_ml || 0)
const avgSolidG = computed(() => props.dashboard.averages.avg_7_solid_g || 0)
// Calculated kcal estimate:
// Pre-milk is ~67 kcal / 100 ml (0.67 kcal/ml)
// Brei is roughly ~70 kcal / 100 g (0.70 kcal/g)
const todayEstimatedKcal = computed(() => {
if (todayFeed.value.total_kcal > 0) return todayFeed.value.total_kcal
const milkKcal = (todayFeed.value.total_consumed_ml || 0) * 0.67
const solidKcal = (todayFeed.value.total_solid_g || 0) * 0.70
return Math.round(milkKcal + solidKcal)
})
// Target kcal (approx 650 kcal for 7.2kg infant at ~85 kcal/kg according to EFSA)
const targetKcal = 650
const kcalPercent = computed(() => {
return Math.min(100, Math.round((todayEstimatedKcal.value / targetKcal) * 100))
})
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-300">
<Utensils class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Ernährung & Beikost-Wissenschaft</h3>
<p class="text-xs text-zinc-400">Milch-zu-Brei Transition nach DGKJ & FKE Dortmund</p>
</div>
</div>
<!-- Quick Status Badges -->
<div class="flex items-center gap-2 text-xs font-mono">
<span class="px-2.5 py-1 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-300 flex items-center gap-1.5">
<Milk class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_consumed_ml }} ml</span>
</span>
<span class="px-2.5 py-1 rounded-xl bg-orange-500/15 border border-orange-500/30 text-orange-300 flex items-center gap-1.5">
<Apple class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_solid_g }} g</span>
</span>
</div>
</div>
<!-- Energy & Transition Hero Card -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- Card 1: Energy & Calorie Goal -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-zinc-400 font-semibold uppercase font-mono flex items-center gap-1">
<Flame class="w-3.5 h-3.5 text-amber-400" />
<span>Tages-Energie</span>
</span>
<span class="font-bold text-amber-300 font-mono">~{{ todayEstimatedKcal }} / {{ targetKcal }} kcal</span>
</div>
<div class="w-full h-2.5 bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
<div
:style="{ width: `${kcalPercent}%` }"
class="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all duration-500"
></div>
</div>
<p class="text-[10px] text-zinc-500 leading-tight">
EFSA-Empfehlung: ca. 80–85 kcal/kg Körpergewicht (~620–680 kcal/Tag bei ~7.4 kg).
</p>
</div>
<!-- Card 2: 7-Tage Milch-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Milch-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-amber-300">{{ avgBottleMl }} ml</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Sichert die Grundversorgung mit Flüssigkeit, Eiweiß und Kalzium im Beikost-Alter.
</p>
</div>
<!-- Card 3: 7-Tage Brei-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Brei-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-orange-300">{{ avgSolidG }} g</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Fördert Kau- und Schluckmotorik sowie Nährstoffdichte (Eisen, Zink).
</p>
</div>
</div>
<!-- 14-Day Transition Micro-Chart -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs pb-1">
<span class="font-semibold text-zinc-300">14-Tage Verlauf: Milch (ml) vs. Brei (g)</span>
<div class="flex items-center gap-3 text-[11px]">
<span class="flex items-center gap-1 text-amber-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-amber-500"></span> Milch (ml / 10)
</span>
<span class="flex items-center gap-1 text-orange-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-orange-500"></span> Brei (g)
</span>
</div>
</div>
<div class="flex items-end justify-between gap-1.5 h-24 pt-2 border-t border-zinc-800/60">
<div
v-for="d in days"
:key="d.date"
class="flex-1 flex flex-col items-center justify-end h-full gap-1 group relative cursor-pointer"
>
<!-- Tooltip hover -->
<div class="hidden group-hover:block absolute bottom-full mb-1 z-20 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] p-1.5 rounded-lg whitespace-nowrap shadow-xl font-mono">
{{ d.weekday }} {{ d.date.slice(5) }}: {{ d.feed_bottles_ml }}ml / {{ d.feed_solid_g }}g
</div>
<div class="w-full flex items-end justify-center gap-0.5 h-16">
<!-- Milk Bar (scaled to 1000ml max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_bottles_ml / 1000) * 100)}%` }"
class="w-1.5 sm:w-2 bg-amber-500/80 group-hover:bg-amber-400 rounded-t-sm transition-all"
></div>
<!-- Solid Bar (scaled to 300g max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_solid_g / 300) * 100)}%` }"
class="w-1.5 sm:w-2 bg-orange-500/80 group-hover:bg-orange-400 rounded-t-sm transition-all"
></div>
</div>
<span class="text-[8px] font-mono text-zinc-500 group-hover:text-zinc-300">
{{ d.date.slice(8) }}
</span>
</div>
</div>
</div>
<!-- Pediatric Guidance Stufenplan (FKE Dortmund) -->
<div class="p-4 rounded-2xl bg-zinc-950/40 border border-zinc-800/60 space-y-2">
<div class="flex items-center gap-1.5 text-xs font-bold text-zinc-300 uppercase tracking-wider font-mono">
<Sparkles class="w-3.5 h-3.5 text-purple-400" />
<span>FKE Dortmund: Ernährungsplan im 1. Lebensjahr</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 text-xs pt-1">
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-emerald-400 block font-mono">① Mittag: Gemüse-Kartoffel-Fleisch</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Liefert wichtiges Eisen und Zink zur Blutbildung und Hirnentwicklung nach Aufbrauchen der pränatalen Speicher.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-sky-400 block font-mono">② Abend: Milch-Getreide-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Kombiniert Kalzium aus Vollmilch mit komplexen Kohlenhydraten (Hafer/Grieß) für langanhaltende Sättigung in der Nacht.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-amber-400 block font-mono">③ Nachmittag: Getreide-Obst-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Milchfreier Brei; Vitamin C aus Früchten optimiert die pflanzliche Eisenresorption und liefert milde Ballaststoffe.
</p>
</div>
</div>
</div>
</div>
</template>
+589
View File
@@ -0,0 +1,589 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { HistoryRow } from '../types'
import { Moon, BarChart2, Calendar, Award } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
}>()
const activePeriod = ref<'7d' | '14d' | '30d'>('7d')
const activeView = ref<'trend' | 'pattern' | 'norms'>('trend')
const activeIndex = ref<number | null>(null)
// Slice history rows for selected period (history is newest first, so reverse for chart chronological left-to-right)
const periodDays = computed(() => {
const count = activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30
const slice = props.history.slice(0, count)
return [...slice].reverse()
})
// Max hours for chart Y-axis scale (normally 18h is good ceiling for infant 24h sleep)
const yMaxHours = 18
// Chart dimensions
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 32
const paddingRight = 16
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
// Coordinates helper
function getY(hours: number): number {
const clamped = Math.max(0, Math.min(yMaxHours, hours))
return paddingTop + innerHeight - (clamped / yMaxHours) * innerHeight
}
// WHO Recommended sleep band (12h to 15h)
const whoYTop = computed(() => getY(15))
const whoYBottom = computed(() => getY(12))
const whoBandHeight = computed(() => whoYBottom.value - whoYTop.value)
// Day bar calculations with 7-day moving average
const bars = computed(() => {
const days = periodDays.value
if (!days.length) return []
const slotWidth = innerWidth / days.length
const barWidth = Math.max(8, Math.min(28, slotWidth * 0.65))
return days.map((day, idx) => {
const xCenter = paddingLeft + (idx + 0.5) * slotWidth
const x = xCenter - barWidth / 2
let nightHours = 0
let napHours = 0
if (day.sessions && day.sessions.length > 0) {
for (const s of day.sessions) {
const mins = s.duration_minutes || 0
if (s.type === 'night') {
nightHours += mins / 60
} else {
napHours += mins / 60
}
}
} else {
nightHours = Math.min(day.total_hours, Math.max(0, day.total_hours * 0.75))
napHours = Math.max(0, day.total_hours - nightHours)
}
const totalHours = Math.max(0, day.total_hours)
const yTotal = getY(totalHours)
const yNight = getY(nightHours)
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
const napHeight = Math.max(0, yNight - yTotal)
// Moving average of previous up to 7 days
const windowStart = Math.max(0, idx - 6)
const windowSlice = days.slice(windowStart, idx + 1)
const avg7 = windowSlice.reduce((acc, d) => acc + d.total_hours, 0) / windowSlice.length
const yAvg7 = getY(avg7)
// WHO Compliance
const isWithinWho = totalHours >= 12 && totalHours <= 15
const isBelowWho = totalHours < 12
const isAboveWho = totalHours > 15
return {
date: day.date,
weekday: day.weekday,
formatted_date: day.formatted_date,
is_today: day.is_today,
totalHours,
nightHours,
napHours,
x,
xCenter,
barWidth,
yTotal,
yNight,
nightHeight,
napHeight,
avg7,
yAvg7,
isWithinWho,
isBelowWho,
isAboveWho,
raw: day,
}
})
})
// Moving average path SVG string
const movingAvgPath = computed(() => {
if (bars.value.length < 2) return ''
return bars.value.reduce((acc, b, i) => {
return i === 0 ? `M ${b.xCenter} ${b.yAvg7}` : `${acc} L ${b.xCenter} ${b.yAvg7}`
}, '')
})
// Selected active bar for tooltip scrubber
const activeBar = computed(() => {
if (activeIndex.value === null) {
return bars.value.length ? bars.value[bars.value.length - 1] : null
}
return bars.value[activeIndex.value] || null
})
// Summary metrics over the selected period
const summary = computed(() => {
const days = periodDays.value
if (!days.length) return { avgTotal: '0', avgNight: '0', avgNaps: '0', whoPercent: 0, napCountAvg: '0' }
let sumTot = 0
let sumNight = 0
let sumNaps = 0
let sumNapCount = 0
let inWhoCount = 0
for (const b of bars.value) {
sumTot += b.totalHours
sumNight += b.nightHours
sumNaps += b.napHours
const napSessions = (b.raw.sessions || []).filter(s => s.type !== 'night')
sumNapCount += napSessions.length
if (b.isWithinWho) inWhoCount++
}
const n = bars.value.length
return {
avgTotal: (sumTot / n).toFixed(1),
avgNight: (sumNight / n).toFixed(1),
avgNaps: (sumNaps / n).toFixed(1),
napCountAvg: (sumNapCount / n).toFixed(1),
whoPercent: Math.round((inWhoCount / n) * 100),
}
})
// 24h Hypnogram Raster Barcode data
const rasterDays = computed(() => {
// Newest at top for barcode
return props.history.slice(0, activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30)
})
function getMinuteFromTime(timeStr: string): number {
if (!timeStr) return 0
const parts = timeStr.split(':')
if (parts.length < 2) return 0
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
}
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = relativeX / svg.width
const svgX = ratio * chartWidth
let closestIdx = 0
let closestDist = Infinity
bars.value.forEach((b, idx) => {
const dist = Math.abs(b.xCenter - svgX)
if (dist < closestDist) {
closestDist = dist
closestIdx = idx
}
})
activeIndex.value = closestIdx
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with View & Period Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-purple-500/15 border border-purple-500/30 flex items-center justify-center text-purple-300">
<Moon class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Schlafarchitektur & Wissenschaft</h3>
<p class="text-xs text-zinc-400">Trend, 24h-Hypnogramm & pädiatrische Normen</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- View Mode Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
@click="activeView = 'trend'"
:class="activeView === 'trend' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<BarChart2 class="w-3.5 h-3.5" />
<span>Trend</span>
</button>
<button
@click="activeView = 'pattern'"
:class="activeView === 'pattern' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Calendar class="w-3.5 h-3.5" />
<span>24h-Muster</span>
</button>
<button
@click="activeView = 'norms'"
:class="activeView === 'norms' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Award class="w-3.5 h-3.5" />
<span>Normen</span>
</button>
</div>
<!-- Period Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
v-for="p in (['7d', '14d', '30d'] as const)"
:key="p"
@click="activePeriod = p; activeIndex = null"
:class="activePeriod === p ? 'bg-zinc-800 text-purple-300 shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-2.5 py-1 rounded-lg transition"
>
{{ p === '7d' ? '7T' : p === '14d' ? '14T' : '30T' }}
</button>
</div>
</div>
</div>
<!-- ================= VIEW 1: TREND BAR CHART ================= -->
<div v-if="activeView === 'trend'" class="space-y-4">
<!-- Active Tooltip / Scrubber Inspector Card -->
<div v-if="activeBar" class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-zinc-200 text-sm">
{{ activeBar.weekday }}, {{ activeBar.formatted_date }}
</span>
<span v-if="activeBar.is_today" class="px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-semibold">
Heute
</span>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-4 font-mono">
<div class="flex items-center gap-1.5">
<span class="text-zinc-400 font-sans">Gesamt:</span>
<span class="font-bold text-purple-300 text-sm">{{ activeBar.totalHours.toFixed(1) }} h</span>
</div>
<div class="flex items-center gap-1.5 text-indigo-300">
<span>🌙</span>
<span>{{ activeBar.nightHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-emerald-300">
<span>🌿</span>
<span>{{ activeBar.napHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-amber-300 hidden sm:flex">
<span class="text-zinc-400 font-sans">Ø7T:</span>
<span>{{ activeBar.avg7.toFixed(1) }}h</span>
</div>
<span
:class="activeBar.isWithinWho ? 'bg-purple-500/15 border-purple-500/30 text-purple-300' : (activeBar.isBelowWho ? 'bg-amber-500/15 border-amber-500/30 text-amber-300' : 'bg-sky-500/15 border-sky-500/30 text-sky-300')"
class="px-2 py-0.5 rounded-md border text-[11px] font-sans font-medium hidden sm:inline-block"
>
{{ activeBar.isWithinWho ? 'WHO Norm (12–15h)' : (activeBar.isBelowWho ? '< 12h' : '> 15h') }}
</span>
</div>
</div>
<!-- Interactive SVG Chart Area -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal grid lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(18)" :y2="getY(18)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(15)" :y2="getY(15)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(12)" :y2="getY(12)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(6)" :y2="getY(6)" />
</g>
<!-- WHO Recommended Band (12h to 15h) -->
<rect
:x="paddingLeft"
:y="whoYTop"
:width="innerWidth"
:height="whoBandHeight"
fill="#a855f7"
fill-opacity="0.08"
stroke="#a855f7"
stroke-opacity="0.3"
stroke-width="1"
stroke-dasharray="3 3"
/>
<text
:x="chartWidth - paddingRight - 4"
:y="whoYTop + 11"
text-anchor="end"
class="fill-purple-400/70 text-[9px] font-mono font-semibold"
>
WHO Richtwert (12–15h)
</text>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<text :x="paddingLeft - 6" :y="getY(18) + 3">18h</text>
<text :x="paddingLeft - 6" :y="getY(15) + 3">15h</text>
<text :x="paddingLeft - 6" :y="getY(12) + 3">12h</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6h</text>
<text :x="paddingLeft - 6" :y="getY(0) - 2">0h</text>
</g>
<!-- Stacked Bars -->
<g v-for="(bar, idx) in bars" :key="bar.date">
<rect
v-if="activeBar?.date === bar.date"
:x="bar.x - 3"
:y="paddingTop"
:width="bar.barWidth + 6"
:height="innerHeight"
fill="#ffffff"
fill-opacity="0.06"
rx="6"
/>
<!-- Night Sleep Segment (Bottom) -->
<rect
:x="bar.x"
:y="bar.yNight"
:width="bar.barWidth"
:height="bar.nightHeight"
class="fill-indigo-500 hover:fill-indigo-400 transition"
rx="3"
/>
<!-- Day Nap Segment (Top of stack) -->
<rect
v-if="bar.napHeight > 0"
:x="bar.x"
:y="bar.yTotal"
:width="bar.barWidth"
:height="bar.napHeight"
class="fill-emerald-400 hover:fill-emerald-300 transition"
rx="3"
/>
<!-- Today Indicator Marker dot -->
<circle
v-if="bar.is_today"
:cx="bar.xCenter"
:cy="bar.yTotal - 6"
r="3"
class="fill-purple-400 animate-pulse"
/>
<!-- X-Axis Labels (Date / Weekday) -->
<text
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
:class="bar.is_today ? 'fill-purple-300 font-bold' : (activeBar?.date === bar.date ? 'fill-zinc-200 font-bold' : 'fill-zinc-500')"
class="text-[9px] font-mono"
>
{{ bar.weekday }}
</text>
<text
v-if="activePeriod !== '30d' || idx % 3 === 0"
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 25"
text-anchor="middle"
class="fill-zinc-600 text-[8px] font-mono"
>
{{ bar.date.slice(8) }}
</text>
</g>
<!-- Moving Average 7-Day Trend Line -->
<path
v-if="movingAvgPath"
:d="movingAvgPath"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="opacity-80"
/>
</svg>
</div>
<!-- Chart Legend & Science Summary -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-3 sm:gap-4 flex-wrap">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-indigo-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">Nacht</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-emerald-400 shrink-0"></span>
<span class="text-zinc-300 font-medium">Naps</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-4 h-0.5 bg-amber-500 shrink-0"></span>
<span class="text-amber-300 font-medium">7T-Schnitt</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-purple-500/20 border border-purple-500/40 shrink-0"></span>
<span class="text-purple-300 font-medium">WHO (12–15h)</span>
</div>
</div>
<div class="flex items-center gap-3 font-mono text-xs">
<span class="text-zinc-400">Ø Gesamt: <strong class="text-purple-300">{{ summary.avgTotal }}h</strong></span>
<span class="text-zinc-400">Norm: <strong class="text-emerald-400">{{ summary.whoPercent }}%</strong></span>
</div>
</div>
</div>
<!-- ================= VIEW 2: 24h PATTERN / HYPNOGRAM RASTER ================= -->
<div v-else-if="activeView === 'pattern'" class="space-y-3">
<div class="text-xs text-zinc-400 flex items-center justify-between">
<span>Täglicher Schlafverlauf von 00:00 bis 24:00 Uhr</span>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1 text-indigo-300">
<span class="w-2 h-2 rounded-full bg-indigo-500"></span> Nacht
</span>
<span class="flex items-center gap-1 text-emerald-300">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Tagesschläfchen
</span>
</div>
</div>
<!-- Time Header Strip (0h, 4h, 8h, 12h, 16h, 20h, 24h) -->
<div class="flex items-center text-[10px] text-zinc-500 font-mono pl-20 pr-2">
<div class="flex-1 text-left">00:00</div>
<div class="flex-1 text-center">06:00</div>
<div class="flex-1 text-center">12:00</div>
<div class="flex-1 text-center">18:00</div>
<div class="w-8 text-right">24:00</div>
</div>
<!-- Raster Bars -->
<div class="space-y-1.5">
<div
v-for="day in rasterDays"
:key="day.date"
class="flex items-center gap-2 group"
>
<!-- Date Label -->
<div class="w-18 shrink-0 font-mono text-[11px] text-zinc-400 group-hover:text-zinc-200 flex items-center gap-1">
<span :class="day.is_today ? 'text-purple-400 font-bold' : ''">{{ day.weekday }}</span>
<span class="text-[10px] text-zinc-500">{{ day.date.slice(5) }}</span>
</div>
<!-- 24h Timeline Track -->
<div class="flex-1 h-6 bg-zinc-950/80 rounded-lg relative overflow-hidden border border-zinc-800/80 group-hover:border-zinc-700">
<!-- 6h Guide Gridlines -->
<div class="absolute inset-0 flex justify-between pointer-events-none opacity-20">
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
</div>
<!-- Sessions Blocks -->
<template v-if="day.sessions && day.sessions.length > 0">
<template v-for="(s, sIdx) in day.sessions" :key="sIdx">
<!-- If valid start time -->
<div
v-if="s.start"
:style="{
left: `${(getMinuteFromTime(s.start) / 1440) * 100}%`,
width: `${Math.max(1.2, ((s.duration_minutes || 60) / 1440) * 100)}%`,
}"
:class="s.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
:title="`${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`"
>
<span v-if="(s.duration_minutes || 0) >= 45" class="truncate px-0.5">
{{ s.duration_minutes }}m
</span>
</div>
</template>
</template>
<div v-else class="text-[10px] text-zinc-600 italic px-2 py-0.5">Keine Einzelschläfchen</div>
</div>
</div>
</div>
</div>
<!-- ================= VIEW 3: NORMS & PEDIATRIC CORRIDORS ================= -->
<div v-else-if="activeView === 'norms'" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- Card 1: 24h Gesamt-Schlaf -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">24h Gesamtschlaf</span>
<span class="text-purple-300 font-mono font-bold">{{ summary.avgTotal }}h / 12–15h</span>
</div>
<!-- Visual corridor progress bar -->
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor shaded region (12h to 15h in 18h scale: 66% to 83%) -->
<div class="absolute left-[66%] right-[16%] top-0 bottom-0 bg-purple-500/20 border-x border-purple-500/50"></div>
<!-- Mila actual marker -->
<div
:style="{ left: `${Math.min(100, (Number(summary.avgTotal) / 18) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-purple-400 rounded-full shadow-lg shadow-purple-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>WHO / AASM Richtwert (12–15h):</strong> Unterstützt die kognitive Konsolidierung, das Längenwachstum durch Wachstumshormone im Tiefschlaf und beugt Übermüdung vor.
</p>
</div>
<!-- Card 2: Nachtschlaf-Architektur -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nachtschlaf</span>
<span class="text-indigo-300 font-mono font-bold">{{ summary.avgNight }}h / 10–11.5h</span>
</div>
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor (10h to 11.5h in 14h scale) -->
<div class="absolute left-[71%] right-[17%] top-0 bottom-0 bg-indigo-500/20 border-x border-indigo-500/50"></div>
<div
:style="{ left: `${Math.min(100, (Number(summary.avgNight) / 14) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-indigo-400 rounded-full shadow-lg shadow-indigo-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>Remo H. Largo (Babyjahre):</strong> Im 2. Lebenshalbjahr konsolidiert sich der Nachtschlaf auf ca. 10–11 Stunden am Stück (mit altersgemäßen Aufwachphasen zur Nahrung/Rückversicherung).
</p>
</div>
<!-- Card 3: Tagesschläfchen-Frequenz -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nap-Frequenz</span>
<span class="text-emerald-300 font-mono font-bold">{{ summary.napCountAvg }} Naps / 2–3 Naps</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Entwicklungsübergang (6–9 Monate):</strong> Die meisten Babys wechseln von 3 kurzen Nickerchen zu 2 stabilen Schläfchen (Vormittag + Nachmittag). Mila liegt mit Ø {{ summary.napCountAvg }} Naps voll im Normbereich.
</p>
</div>
<!-- Card 4: Wachfenster-Korridor -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Sweet-Spot Wachfenster</span>
<span class="text-amber-300 font-mono font-bold">2h 15m – 3h 00m</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Schlafdruck vs. Übermüdung:</strong> Nach 2h 15m baut sich der ideale Schlafdruck auf. Ab 3h 15m steigt der Cortisol- und Adrenalinspiegel, was das Einschlafen erschwert.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,579 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Moon, Sun, Baby, Milk, HeartPulse, Check, X } from 'lucide-vue-next'
import { logEvent, stopSleepEvent, getLocalDateString } from '../api'
const props = defineProps<{
isOpen: boolean
isSleeping: boolean
activeSleepId?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'logged'): void
}>()
const activeTab = ref<'sleep' | 'feeding' | 'diaper' | 'growth'>('sleep')
const feedingSubTab = ref<'bottle' | 'solid'>('bottle')
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
// --- Sleep Form ---
const sleepType = ref<'nap' | 'night'>('nap')
const sleepLocation = ref('bett')
const sleepStartTime = ref('')
const sleepEndTime = ref('')
const sleepNote = ref('')
// --- Bottle Form ---
const bottleMl = ref<number | ''>(150)
const bottleMilkType = ref('Pre')
const bottleTime = ref('')
const bottleNote = ref('')
// --- Solid Form ---
const solidItem = ref('')
const solidGrams = ref<number | ''>(100)
const solidKcal = ref<number | ''>(70)
const solidTime = ref('')
const solidNote = ref('')
// --- Diaper Form ---
const diaperTexture = ref('weich')
const diaperNote = ref('')
// --- Growth Form ---
const growthWeight = ref<number | ''>(7.2)
const growthHeight = ref<number | ''>(66.0)
const growthHeadCirc = ref<number | ''>(42.5)
const growthNote = ref('')
const targetDate = ref(getLocalDateString())
function resetMessages() {
errorMessage.value = null
successMessage.value = null
}
async function handleStartSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
date: targetDate.value || undefined,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schläfchen gestartet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Starten des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleStopSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await stopSleepEvent({
subject: 'mila',
event_id: props.activeSleepId,
})
successMessage.value = 'Schläfchen erfolgreich beendet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Beenden des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleCustomSleepSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
let startIso: string | undefined = undefined
let endIso: string | undefined = undefined
if (sleepStartTime.value) {
startIso = `${baseDate}T${sleepStartTime.value}:00`
}
if (sleepEndTime.value) {
if (sleepStartTime.value && sleepEndTime.value < sleepStartTime.value) {
// Crosses midnight: end timestamp is next day
const [y, m, d] = baseDate.split('-').map(Number)
const nextDay = new Date(y, m - 1, d + 1)
const ny = nextDay.getFullYear()
const nm = String(nextDay.getMonth() + 1).padStart(2, '0')
const nd = String(nextDay.getDate()).padStart(2, '0')
endIso = `${ny}-${nm}-${nd}T${sleepEndTime.value}:00`
} else {
endIso = `${baseDate}T${sleepEndTime.value}:00`
}
}
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
start: startIso,
end: endIso,
date: baseDate,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schlaf eingetragen!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleFeedingSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
if (feedingSubTab.value === 'bottle') {
const startIso = bottleTime.value ? `${baseDate}T${bottleTime.value}:00` : undefined
await logEvent({
kind: 'feeding.bottle',
subject: 'mila',
payload: {
consumed_ml: Number(bottleMl.value) || 0,
milk_type: bottleMilkType.value,
},
start: startIso,
date: baseDate,
note: bottleNote.value || undefined,
})
successMessage.value = `${bottleMl.value} ml Fläschchen eingetragen!`
} else {
const startIso = solidTime.value ? `${baseDate}T${solidTime.value}:00` : undefined
await logEvent({
kind: 'feeding.solid',
subject: 'mila',
payload: {
item: solidItem.value || 'Brei',
consumed_g: Number(solidGrams.value) || 0,
kcal: Number(solidKcal.value) || 0,
},
start: startIso,
date: baseDate,
note: solidNote.value || undefined,
})
successMessage.value = `${solidGrams.value} g Brei eingetragen!`
}
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleDiaperQuickSubmit(pee: boolean, poo: boolean) {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'diaper.change',
subject: 'mila',
payload: {
pee,
poo,
texture: poo ? diaperTexture.value : undefined,
},
date: baseDate,
note: diaperNote.value || undefined,
})
successMessage.value = 'Windelwechsel erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleGrowthSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'growth.measurement',
subject: 'mila',
payload: {
weight_kg: Number(growthWeight.value) || null,
height_cm: Number(growthHeight.value) || null,
head_circ_cm: Number(growthHeadCirc.value) || null,
},
date: baseDate,
note: growthNote.value || undefined,
})
successMessage.value = 'Messung erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-md">
<div class="w-full sm:max-w-lg bg-zinc-950 border border-zinc-800 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- Modal Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-2">
<span class="text-xl">✨</span>
<h2 class="text-base font-bold text-white font-heading">Ereignis erfassen</h2>
</div>
<button @click="emit('close')" class="p-1 rounded-xl text-zinc-400 hover:text-white hover:bg-zinc-800 transition">
<X class="w-5 h-5" />
</button>
</div>
<!-- Tab Navigation -->
<div class="grid grid-cols-4 p-1.5 bg-zinc-900 border-b border-zinc-800 text-xs font-semibold">
<button
@click="activeTab = 'sleep'; resetMessages()"
:class="activeTab === 'sleep' ? 'bg-purple-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Moon class="w-3.5 h-3.5" />
<span>Schlaf</span>
</button>
<button
@click="activeTab = 'feeding'; resetMessages()"
:class="activeTab === 'feeding' ? 'bg-amber-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Milk class="w-3.5 h-3.5" />
<span>Nahrung</span>
</button>
<button
@click="activeTab = 'diaper'; resetMessages()"
:class="activeTab === 'diaper' ? 'bg-teal-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Baby class="w-3.5 h-3.5" />
<span>Windel</span>
</button>
<button
@click="activeTab = 'growth'; resetMessages()"
:class="activeTab === 'growth' ? 'bg-pink-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<HeartPulse class="w-3.5 h-3.5" />
<span>Wachstum</span>
</button>
</div>
<!-- Date Selection Bar (Retroactive Tracking) -->
<div class="px-5 py-2 bg-zinc-900/60 border-b border-zinc-800 flex items-center justify-between text-xs text-zinc-400">
<span class="flex items-center gap-1.5 font-medium">
<span>📅</span>
<span>Ereignis-Datum:</span>
</span>
<input
v-model="targetDate"
type="date"
class="bg-zinc-950 border border-zinc-700/80 rounded-lg px-2.5 py-1 text-xs text-zinc-100 font-mono focus:border-purple-500 focus:outline-none"
/>
</div>
<!-- Messages -->
<div v-if="errorMessage" class="mx-5 mt-4 p-3 rounded-xl bg-red-950/60 border border-red-800/80 text-red-200 text-xs">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="mx-5 mt-4 p-3 rounded-xl bg-emerald-950/60 border border-emerald-800/80 text-emerald-200 text-xs flex items-center gap-2">
<Check class="w-4 h-4 text-emerald-400" />
<span>{{ successMessage }}</span>
</div>
<!-- Tab Content Area -->
<div class="p-5 overflow-y-auto space-y-4 flex-1">
<!-- ================= TAB 1: SCHLAF ================= -->
<div v-if="activeTab === 'sleep'" class="space-y-4">
<!-- 1-Tap Quick Action -->
<div class="p-4 rounded-2xl bg-zinc-900/80 border border-purple-500/20 space-y-3">
<div class="text-xs font-semibold text-purple-300 uppercase tracking-wider">1-Tap Sofort-Logging</div>
<div v-if="!isSleeping" class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="sleepType = 'nap'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-purple-600/20 active:scale-95 transition"
>
<Moon class="w-4 h-4" />
<span>Schläfchen JETZT starten</span>
</button>
<button
@click="sleepType = 'night'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sky-300 border border-sky-500/30 font-semibold text-sm flex items-center justify-center gap-2 active:scale-95 transition"
>
<span>🌙 Nachtschlaf starten</span>
</button>
</div>
<div v-else class="space-y-2">
<button
@click="handleStopSleepNow()"
:disabled="isSubmitting"
class="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-extrabold text-sm flex items-center justify-center gap-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition animate-pulse"
>
<Sun class="w-4 h-4" />
<span>Schlaf JETZT beenden (Aufgewacht!)</span>
</button>
</div>
</div>
<!-- Custom Sleep Form -->
<form @submit.prevent="handleCustomSleepSubmit" class="space-y-3 pt-2 border-t border-zinc-800">
<div class="text-xs font-semibold text-zinc-400">Oder manuell mit Uhrzeit:</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Typ</label>
<select v-model="sleepType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="nap">Tagesschläfchen (Nap)</option>
<option value="night">Nachtschlaf</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Ort</label>
<select v-model="sleepLocation" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="bett">Bett</option>
<option value="kinderwagen">Kinderwagen</option>
<option value="auto">Auto</option>
<option value="trage">Trage</option>
<option value="sofa">Sofa</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Startzeit</label>
<input v-model="sleepStartTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Endzeit (optional)</label>
<input v-model="sleepEndTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="sleepNote" type="text" placeholder="z.B. schnell eingeschlafen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full py-2.5 rounded-xl bg-purple-700 hover:bg-purple-600 text-white font-semibold text-xs transition"
>
Schlaf manuell speichern
</button>
</form>
</div>
<!-- ================= TAB 2: NAHRUNG ================= -->
<div v-if="activeTab === 'feeding'" class="space-y-4">
<!-- Sub-tab Toggle -->
<div class="flex rounded-xl bg-zinc-900 p-1 border border-zinc-800 text-xs">
<button
@click="feedingSubTab = 'bottle'"
:class="feedingSubTab === 'bottle' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🍼 Fläschchen
</button>
<button
@click="feedingSubTab = 'solid'"
:class="feedingSubTab === 'solid' ? 'bg-orange-500/20 text-orange-300 border border-orange-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🥣 Brei / Beikost
</button>
</div>
<!-- Bottle Section -->
<div v-if="feedingSubTab === 'bottle'" class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl (ml)</label>
<div class="grid grid-cols-6 gap-1.5">
<button
v-for="ml in [60, 90, 120, 150, 180, 210]"
:key="ml"
type="button"
@click="bottleMl = ml"
:class="bottleMl === ml ? 'bg-amber-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ ml }}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Getrunkene Menge (ml)</label>
<input v-model="bottleMl" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Milchtyp</label>
<select v-model="bottleMilkType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="Pre">Pre</option>
<option value="Muttermilch">Muttermilch</option>
<option value="1er">1er Nahrung</option>
</select>
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Uhrzeit (leer = Jetzt)</label>
<input v-model="bottleTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white font-bold text-sm shadow-lg shadow-amber-600/20 active:scale-95 transition"
>
Flasche speichern
</button>
</div>
<!-- Solid Section -->
<div v-else class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Brei-Bezeichnung</label>
<input v-model="solidItem" type="text" placeholder="z.B. Kürbis-Kartoffel Brei" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl Gramm</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="g in [50, 100, 150, 190]"
:key="g"
type="button"
@click="solidGrams = g"
:class="solidGrams === g ? 'bg-orange-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ g }}g
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Menge (g)</label>
<input v-model="solidGrams" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kalorien (~kcal)</label>
<input v-model="solidKcal" type="number" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-500 hover:to-amber-500 text-white font-bold text-sm shadow-lg shadow-orange-600/20 active:scale-95 transition"
>
Brei speichern
</button>
</div>
</div>
<!-- ================= TAB 3: WINDEL ================= -->
<div v-if="activeTab === 'diaper'" class="space-y-4">
<div class="text-xs font-semibold text-teal-300 uppercase tracking-wider">1-Tap Windelwechsel</div>
<div class="grid grid-cols-3 gap-2">
<button
@click="handleDiaperQuickSubmit(true, false)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-teal-500/30 hover:border-teal-500/60 hover:bg-teal-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💧</span>
<span class="text-xs font-bold text-teal-300">Nur Pipi</span>
</button>
<button
@click="handleDiaperQuickSubmit(false, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-amber-500/30 hover:border-amber-500/60 hover:bg-amber-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💩</span>
<span class="text-xs font-bold text-amber-300">Nur Kacka</span>
</button>
<button
@click="handleDiaperQuickSubmit(true, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-emerald-500/30 hover:border-emerald-500/60 hover:bg-emerald-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">🧷</span>
<span class="text-xs font-bold text-emerald-300">Beides</span>
</button>
</div>
<div class="space-y-3 pt-3 border-t border-zinc-800">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Konsistenz (optional)</label>
<select v-model="diaperTexture" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="weich">Weich</option>
<option value="breiig">Breiig</option>
<option value="fest">Fest</option>
<option value="flüssig">Flüssig</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="diaperNote" type="text" placeholder="z.B. nach dem Aufwachen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
</div>
<!-- ================= TAB 4: WACHSTUM ================= -->
<div v-if="activeTab === 'growth'" class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Gewicht (kg)</label>
<input v-model="growthWeight" type="number" step="0.05" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Größe (cm)</label>
<input v-model="growthHeight" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kopf (cm)</label>
<input v-model="growthHeadCirc" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="growthNote" type="text" placeholder="z.B. U5 Untersuchung beim Kinderarzt" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleGrowthSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold text-sm shadow-lg shadow-pink-600/20 active:scale-95 transition"
>
Messung speichern
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { GrowthMeasurement } from '../types'
import { Ruler, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
currentAgeMonths?: number
}>()
const activeMetric = ref<'weight' | 'height'>('weight')
const activeMonthHover = ref<number | null>(null)
// WHO Girls Growth Standards 0-18 Months
const WHO_WEIGHT = [
{ m: 0, p3: 2.4, p15: 2.8, p50: 3.2, p85: 3.7, p97: 4.2 },
{ m: 1, p3: 3.2, p15: 3.6, p50: 4.2, p85: 4.8, p97: 5.4 },
{ m: 2, p3: 4.0, p15: 4.5, p50: 5.1, p85: 5.8, p97: 6.5 },
{ m: 3, p3: 4.6, p15: 5.2, p50: 5.8, p85: 6.6, p97: 7.4 },
{ m: 4, p3: 5.1, p15: 5.7, p50: 6.4, p85: 7.3, p97: 8.1 },
{ m: 5, p3: 5.5, p15: 6.1, p50: 6.9, p85: 7.8, p97: 8.7 },
{ m: 6, p3: 5.8, p15: 6.5, p50: 7.3, p85: 8.2, p97: 9.2 },
{ m: 7, p3: 6.1, p15: 6.8, p50: 7.6, p85: 8.6, p97: 9.6 },
{ m: 8, p3: 6.3, p15: 7.0, p50: 7.9, p85: 8.9, p97: 10.0 },
{ m: 9, p3: 6.6, p15: 7.3, p50: 8.2, p85: 9.3, p97: 10.4 },
{ m: 10, p3: 6.8, p15: 7.5, p50: 8.5, p85: 9.6, p97: 10.7 },
{ m: 11, p3: 7.0, p15: 7.7, p50: 8.7, p85: 9.9, p97: 11.0 },
{ m: 12, p3: 7.1, p15: 7.9, p50: 8.9, p85: 10.1, p97: 11.3 },
{ m: 15, p3: 7.7, p15: 8.5, p50: 9.6, p85: 10.9, p97: 12.2 },
{ m: 18, p3: 8.2, p15: 9.1, p50: 10.2, p85: 11.6, p97: 13.0 },
]
const WHO_HEIGHT = [
{ m: 0, p3: 45.6, p15: 47.3, p50: 49.1, p85: 51.0, p97: 52.7 },
{ m: 1, p3: 50.0, p15: 51.7, p50: 53.7, p85: 55.6, p97: 57.4 },
{ m: 2, p3: 53.2, p15: 55.0, p50: 57.1, p85: 59.1, p97: 60.9 },
{ m: 3, p3: 55.8, p15: 57.7, p50: 59.8, p85: 61.9, p97: 63.8 },
{ m: 4, p3: 58.0, p15: 59.9, p50: 62.1, p85: 64.3, p97: 66.2 },
{ m: 5, p3: 59.9, p15: 61.8, p50: 64.0, p85: 66.3, p97: 68.2 },
{ m: 6, p3: 61.5, p15: 63.5, p50: 65.7, p85: 68.0, p97: 70.0 },
{ m: 7, p3: 62.9, p15: 65.0, p50: 67.3, p85: 69.6, p97: 71.6 },
{ m: 8, p3: 64.3, p15: 66.4, p50: 68.7, p85: 71.1, p97: 73.2 },
{ m: 9, p3: 65.6, p15: 67.8, p50: 70.1, p85: 72.5, p97: 74.7 },
{ m: 10, p3: 66.8, p15: 69.0, p50: 71.5, p85: 73.9, p97: 76.1 },
{ m: 11, p3: 68.0, p15: 70.3, p50: 72.8, p85: 75.2, p97: 77.5 },
{ m: 12, p3: 69.2, p15: 71.4, p50: 74.0, p85: 76.5, p97: 78.9 },
{ m: 15, p3: 72.4, p15: 74.8, p50: 77.5, p85: 80.2, p97: 82.7 },
{ m: 18, p3: 75.2, p15: 77.8, p50: 80.7, p85: 83.5, p97: 86.2 },
]
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 38
const paddingRight = 20
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
const maxMonth = 18
// Min/Max Y scale based on active metric
const yMin = computed(() => (activeMetric.value === 'weight' ? 2 : 44))
const yMax = computed(() => (activeMetric.value === 'weight' ? 14 : 90))
function getX(month: number): number {
const clamped = Math.max(0, Math.min(maxMonth, month))
return paddingLeft + (clamped / maxMonth) * innerWidth
}
function getY(val: number): number {
const clamped = Math.max(yMin.value, Math.min(yMax.value, val))
return paddingTop + innerHeight - ((clamped - yMin.value) / (yMax.value - yMin.value)) * innerHeight
}
const currentTable = computed(() => (activeMetric.value === 'weight' ? WHO_WEIGHT : WHO_HEIGHT))
// Path builder for WHO percentile lines
function buildPath(key: 'p3' | 'p15' | 'p50' | 'p85' | 'p97'): string {
const pts = currentTable.value.map((row) => `${getX(row.m).toFixed(1)},${getY(row[key]).toFixed(1)}`)
return `M ${pts.join(' L ')}`
}
// Shaded polygon area between P15 and P85
const normalBandPath = computed(() => {
const topPts = currentTable.value.map((r) => `${getX(r.m).toFixed(1)},${getY(r.p85).toFixed(1)}`)
const botPts = [...currentTable.value].reverse().map((r) => `${getX(r.m).toFixed(1)},${getY(r.p15).toFixed(1)}`)
return `M ${topPts.join(' L ')} L ${botPts.join(' L ')} Z`
})
// Mila's plotted points
const plottedPoints = computed(() => {
return props.measurements
.filter((m) => (activeMetric.value === 'weight' ? m.weight_kg !== null : m.height_cm !== null))
.map((m) => {
const val = (activeMetric.value === 'weight' ? m.weight_kg : m.height_cm) as number
const pctStr = activeMetric.value === 'weight' ? m.weight_pct : m.height_pct
return {
id: m.id,
date_pretty: m.date_pretty,
age_str: m.age_str,
age_months: m.age_months,
val,
pctStr,
cx: getX(m.age_months),
cy: getY(val),
}
})
})
// Active inspected month info for hover/touch
const inspectedData = computed(() => {
const m = activeMonthHover.value ?? (props.currentAgeMonths ? Math.round(props.currentAgeMonths) : 7)
const clampedM = Math.max(0, Math.min(maxMonth, m))
// Find nearest WHO row
let nearest = currentTable.value[0]
let minDist = Infinity
for (const row of currentTable.value) {
const d = Math.abs(row.m - clampedM)
if (d < minDist) {
minDist = d
nearest = row
}
}
// Find if Mila has a measurement near this month
const milaPt = plottedPoints.value.find((pt) => Math.abs(pt.age_months - clampedM) <= 1)
return {
month: clampedM,
p50: nearest.p50,
p15: nearest.p15,
p85: nearest.p85,
unit: activeMetric.value === 'weight' ? 'kg' : 'cm',
milaPt,
}
})
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = (relativeX / svg.width) * chartWidth
const month = ((ratio - paddingLeft) / innerWidth) * maxMonth
activeMonthHover.value = Math.max(0, Math.min(maxMonth, Math.round(month)))
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with Metric Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-pink-500/15 border border-pink-500/30 flex items-center justify-center text-pink-300">
<Ruler class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">WHO Wachstums- & Perzentilenkurven</h3>
<p class="text-xs text-zinc-400">Offizielle WHO-Standards für Mädchen (0–18 Monate)</p>
</div>
</div>
<!-- Toggle Gewicht vs Größe -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold self-start sm:self-auto">
<button
@click="activeMetric = 'weight'"
:class="activeMetric === 'weight' ? 'bg-pink-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
⚖️ Gewicht (kg)
</button>
<button
@click="activeMetric = 'height'"
:class="activeMetric === 'height' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
📏 Körpergröße (cm)
</button>
</div>
</div>
<!-- Active Scrubber Inspector Card -->
<div class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<Sparkles class="w-4 h-4 text-pink-400 shrink-0" />
<span class="font-bold text-zinc-200">
Alter: {{ inspectedData.month }} Monate
</span>
<span class="text-zinc-400">· WHO P50 Median:</span>
<strong class="font-mono text-purple-300">{{ inspectedData.p50 }} {{ inspectedData.unit }}</strong>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3 font-mono">
<span class="text-zinc-400">
Normalbereich (P15–P85):
<strong class="text-zinc-300">{{ inspectedData.p15 }} – {{ inspectedData.p85 }} {{ inspectedData.unit }}</strong>
</span>
<span
v-if="inspectedData.milaPt"
class="px-2 py-0.5 rounded-md bg-pink-500/20 border border-pink-500/40 text-pink-300 font-semibold"
>
Mila: {{ inspectedData.milaPt.val }} {{ inspectedData.unit }} ({{ inspectedData.milaPt.pctStr }})
</span>
</div>
</div>
<!-- Interactive SVG Curve Canvas -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal Grid Lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line
v-for="yTick in (activeMetric === 'weight' ? [3, 6, 9, 12] : [50, 60, 70, 80])"
:key="yTick"
:x1="paddingLeft"
:x2="chartWidth - paddingRight"
:y1="getY(yTick)"
:y2="getY(yTick)"
/>
</g>
<!-- Shaded Normal Range Corridor (P15 to P85) -->
<path
:d="normalBandPath"
fill="#ec4899"
fill-opacity="0.08"
/>
<!-- P3 Curve (Lower bound) -->
<path
:d="buildPath('p3')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- P15 Curve -->
<path
:d="buildPath('p15')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P50 Median Curve (Prominent) -->
<path
:d="buildPath('p50')"
fill="none"
stroke="#a855f7"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- P85 Curve -->
<path
:d="buildPath('p85')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P97 Curve (Upper bound) -->
<path
:d="buildPath('p97')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<template v-if="activeMetric === 'weight'">
<text :x="paddingLeft - 6" :y="getY(14) + 3">14kg</text>
<text :x="paddingLeft - 6" :y="getY(10) + 3">10kg</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6kg</text>
<text :x="paddingLeft - 6" :y="getY(2) - 2">2kg</text>
</template>
<template v-else>
<text :x="paddingLeft - 6" :y="getY(85) + 3">85cm</text>
<text :x="paddingLeft - 6" :y="getY(70) + 3">70cm</text>
<text :x="paddingLeft - 6" :y="getY(55) + 3">55cm</text>
</template>
</g>
<!-- Month X-Axis Ticks & Labels -->
<g class="fill-zinc-500 text-[9px] font-mono" text-anchor="middle">
<template v-for="m in [0, 2, 4, 6, 8, 10, 12, 15, 18]" :key="m">
<line
:x1="getX(m)"
:x2="getX(m)"
:y1="chartHeight - paddingBottom"
:y2="chartHeight - paddingBottom + 4"
stroke="#3f3f46"
/>
<text :x="getX(m)" :y="chartHeight - paddingBottom + 16">{{ m }}m</text>
</template>
</g>
<!-- Vertical Scrubber Guideline -->
<line
v-if="activeMonthHover !== null"
:x1="getX(activeMonthHover)"
:x2="getX(activeMonthHover)"
:y1="paddingTop"
:y2="chartHeight - paddingBottom"
stroke="#ffffff"
stroke-width="1.5"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- Mila's Actual Measurement Markers -->
<g v-for="pt in plottedPoints" :key="pt.id">
<!-- Outer glow -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="8"
fill="#ec4899"
fill-opacity="0.25"
class="animate-pulse"
/>
<!-- Core dot -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="4.5"
fill="#f43f5e"
stroke="#ffffff"
stroke-width="2"
/>
</g>
</svg>
</div>
<!-- Science Legend & Notes -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-1 rounded bg-purple-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">P50 (Median)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-2.5 rounded bg-pink-500/20 border border-pink-500/40 shrink-0"></span>
<span class="text-zinc-300 font-medium">P15–P85 (Normalspanne)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500 border border-white shrink-0"></span>
<span class="text-pink-300 font-medium">Mila Messpunkt</span>
</div>
</div>
<div class="text-[11px] text-zinc-400">
WHO Child Growth Standards (WHO MGRS)
</div>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// Client-side canonical path enforcement
if (typeof window !== 'undefined' && (window.location.pathname === '' || window.location.pathname === '/')) {
window.location.replace('/tracking/mila/')
}
createApp(App).mount('#app')
// Reliable Service Worker registration (catches readyState complete and load)
if ('serviceWorker' in navigator) {
const registerSW = () => {
navigator.serviceWorker
.register('/tracking/mila/sw.js', { scope: '/tracking/mila/' })
.then((reg) => {
// Check for updates
reg.update().catch(() => {})
})
.catch((err) => console.warn('Mila Service Worker registration failed:', err))
}
if (document.readyState === 'complete') {
registerSW()
} else {
window.addEventListener('load', registerSW)
}
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@layer base {
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.font-heading {
font-family: 'Syne', sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
+159
View File
@@ -0,0 +1,159 @@
export interface NapSession {
id: string
start: string
end: string
sleep_type: string
location: string
note: string
duration_minutes: number | null
is_open: boolean
}
export interface FeedingItem {
id?: string
time: string
consumed_ml: number | null
milk_type: string
item: string
solid_g: number | null
kcal: number | null
kind: string
note: string
}
export interface DiaperItem {
id?: string
time: string
pee: boolean
poo: boolean
texture: string
note: string
}
export interface HistoryRow {
date: string
weekday: string
formatted_date: string
is_today: boolean
total_hours: number
night_duration_str: string
nap_str: string
feed_bottles_ml: number
feed_bottles_count: number
feed_solid_g: number
feed_solid_count: number
diaper_changes: number
diaper_dirty: number
sessions: {
id?: string
start: string
end: string
type: string
duration_minutes: number | null
is_open: boolean
note: string
location?: string
}[]
}
export interface GrowthMeasurement {
id: string
date_str: string
date_pretty: string
age_str: string
age_months: number
weight_kg: number | null
weight_pct: string
height_cm: number | null
height_pct: string
head_circ_cm: number | null
note: string
}
export interface DayDetailData {
date: string
weekday: string
sleep_sessions: NapSession[]
feedings: FeedingItem[]
diapers: DiaperItem[]
growth?: GrowthMeasurement[]
}
export interface AgeNorms {
category: string
sweet_spot_wake_window_min: [number, number]
total_sleep_norm_hours: [number, number]
night_sleep_norm_hours: [number, number]
nap_count_norm: [number, number]
feed_interval_hours: [number, number]
guidelines_reference: string
}
export interface MilaDashboardData {
status: string
execution_time_ms: number
git_status: string
git_conflict: boolean
metadata: {
subject: string
today_date: string
today_pretty: string
age_str: string
}
live_status: {
is_sleeping: boolean
active_sleep_id: string | null
active_sleep_duration_str: string
active_sleep_start_time: string
active_sleep_iso: string
wake_window_minutes: number | null
wake_window_str: string
last_wake_time_str: string
last_wake_iso: string
last_feed_time: string | null
last_feed_elapsed_minutes: number | null
last_feed_item: string | null
last_feed_amount: number | null
last_diaper_time: string | null
last_diaper_elapsed_minutes: number | null
last_stool_time: string | null
last_stool_elapsed_minutes: number | null
}
age_norms?: AgeNorms
today: {
total_sleep_hours_str: string
nap_count: number
nap_minutes: number
night_duration_str: string
naps: NapSession[]
nights: NapSession[]
feedings: FeedingItem[]
feeding_summary: {
bottle_count: number
total_consumed_ml: number
solid_count: number
total_solid_g: number
total_kcal: number
}
diapers: DiaperItem[]
diaper_summary: {
total_changes: number
wet_count: number
dirty_count: number
}
}
averages: {
avg_7_tot: string
avg_7_night: string
avg_7_naps: string
avg_30_tot: string
avg_7_feeding_ml?: number
avg_7_solid_g?: number
avg_7_diapers?: number
}
history: HistoryRow[]
growth: {
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/tracking/mila/',
plugins: [
vue(),
tailwindcss(),
],
server: {
port: 5173,
proxy: {
'/duckdb-api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})