mitai-jinkendo/frontend/src/pages/History.jsx
Lars 8c60601ed1
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s
feat: implement touch-friendly KPI details with bottom sheet interaction
- Added a new button for displaying KPI details on touch devices, replacing hover functionality.
- Introduced a bottom sheet component to present detailed information when the info button is clicked.
- Enhanced the BodyKpiOverview component to detect touch UI and adjust interactions accordingly.
- Updated CSS styles for new touch elements, ensuring a responsive and user-friendly design.
2026-04-19 16:46:05 +02:00

1502 lines
71 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory } from '../utils/calc'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
function rollingAvg(arr, key, window=7) {
return arr.map((d,i) => {
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
})
}
const fmtDate = d => dayjs(d).format('DD.MM')
function NavToCaliper() {
const nav = useNavigate()
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/caliper')}>Caliper-Daten <ChevronRight size={10}/></button>
}
function NavToCircum() {
const nav = useNavigate()
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
onClick={()=>nav('/circum')}>Umfang-Daten <ChevronRight size={10}/></button>
}
function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
<div style={{padding:32,textAlign:'center',color:'var(--text3)',fontSize:13}}>
<div style={{marginBottom:12}}>{text}</div>
{to && <button className="btn btn-primary" onClick={()=>nav(to)}>{toLabel||'Daten erfassen'}</button>}
</div>
)
}
function SectionHeader({ title, to, toLabel, lastUpdated }) {
const nav = useNavigate()
return (
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>{title}</h2>
<div style={{display:'flex',gap:8,alignItems:'center'}}>
{lastUpdated && <span style={{fontSize:11,color:'var(--text3)'}}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
{to && (
<button className="btn btn-secondary" style={{fontSize:12,padding:'5px 10px'}} onClick={()=>nav(to)}>
{toLabel||'Daten'} <ChevronRight size={12}/>
</button>
)}
</div>
</div>
)
}
function RuleCard({ item }) {
const [open, setOpen] = useState(false)
const color = getStatusColor(item.status)
return (
<div style={{border:`1px solid ${color}33`,borderRadius:8,marginBottom:6,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:getStatusBg(item.status)+'88',cursor:'pointer'}} onClick={()=>setOpen(o=>!o)}>
<span style={{fontSize:15}}>{item.icon}</span>
<div style={{flex:1}}>
<div style={{fontSize:11,fontWeight:600,color,textTransform:'uppercase',letterSpacing:'0.04em'}}>{item.category}</div>
<div style={{fontSize:13,fontWeight:500}}>{item.title}</div>
</div>
{item.value && <span style={{fontSize:14,fontWeight:700,color}}>{item.value}</span>}
{open ? <ChevronUp size={14} color="var(--text3)"/> : <ChevronDown size={14} color="var(--text3)"/>}
</div>
{open && <div style={{padding:'8px 12px',fontSize:12,color:'var(--text2)',lineHeight:1.6,
borderTop:`1px solid ${color}22`}}>{item.detail}</div>}
</div>
)
}
function verdictShort(status) {
if (status === 'good') return 'Gut'
if (status === 'warn') return 'Hinweis'
return 'Achtung'
}
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */
function buildBodyKpiTiles({
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW,
}) {
const tiles = []
if (summary.weight_kg != null) {
const t90 = trendPeriods.find(t => t.label === '90T')
const t30 = trendPeriods.find(t => t.label === '30T')
const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg
let st = 'good'
let vs = 'Stabil'
if (d != null) {
if (d < -0.25) { st = 'good'; vs = 'Trend ↓' }
else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' }
else { st = 'good'; vs = 'Stabil' }
}
const trendBits = trendPeriods.length
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
: ''
const hoverBody = [
'Gewicht im gewählten Zeitraum (letzter Messwert).',
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
minW != null && maxW != null ? `Min. / Max.: ${minW} ${maxW} kg` : null,
trendBits ? `Änderung: ${trendBits}` : null,
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
].filter(Boolean).join('\n')
tiles.push({
key: 'weight',
category: 'Gewicht',
icon: '⚖️',
value: `${summary.weight_kg} kg`,
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
verdict: vs,
status: st,
hoverTop: 'Gewicht',
hoverBody,
keys: ['weight_aktuell', 'weight_trend'],
})
}
const kfRule = rules.find(r => r.category === 'Körperfett')
if (summary.body_fat_pct != null) {
tiles.push({
key: 'bf',
category: 'Körperfett',
icon: '🫧',
value: `${summary.body_fat_pct}%`,
valueColor: bfCat?.color,
sublabel: bfCat?.label || summary.bf_category_label || '',
verdict: verdictShort(kfRule?.status || 'good'),
status: kfRule?.status || 'good',
hoverTop: kfRule?.title || 'Körperfettanteil',
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const mmRule = rules.find(r => r.category === 'Muskelmasse')
if (summary.lean_mass_kg != null || summary.ffmi != null) {
const valParts = []
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
tiles.push({
key: 'lean_ffmi',
category: 'Magermasse',
icon: '💪',
value: valParts.join(' · ') || '—',
sublabel: 'Lean / FFMI',
verdict: mmRule ? verdictShort(mmRule.status) : '—',
status: mmRule?.status || 'good',
hoverTop: mmRule?.title || 'Muskelmasse',
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const bmiRule = rules.find(r => r.category === 'BMI')
if (bmiRule) {
tiles.push({
key: 'bmi',
category: 'BMI',
icon: '📋',
value: bmiRule.value || '—',
sublabel: 'Body-Mass-Index',
verdict: verdictShort(bmiRule.status),
status: bmiRule.status,
hoverTop: bmiRule.title,
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whrRule = rules.find(r => r.category === 'Fettverteilung')
if (summary.whr != null) {
const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85)
tiles.push({
key: 'whr',
category: 'Fettverteilung',
icon: '📐',
value: String(summary.whr),
sublabel: 'WHR · Taille ÷ Hüfte',
verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'),
status: whrRule?.status || (ok ? 'good' : 'warn'),
hoverTop: whrRule?.title || 'Waist-Hip-Ratio',
hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whtrRule = rules.find(r => r.category === 'Taille/Größe')
if (summary.whtr != null) {
const ok = summary.whtr < 0.5
tiles.push({
key: 'whtr',
category: 'Taille/Größe',
icon: '📏',
value: String(summary.whtr),
sublabel: 'WHtR · Taille ÷ Größe',
verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'),
status: whtrRule?.status || (ok ? 'good' : 'warn'),
hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio',
hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const lastRule = rules.find(r => r.category.startsWith('Seit letzter'))
if (lastRule) {
tiles.push({
key: 'delta',
category: 'Messvergleich',
icon: '📊',
value: lastRule.value || '—',
sublabel: 'seit Vorperiode',
verdict: verdictShort(lastRule.status),
status: lastRule.status,
hoverTop: lastRule.title,
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
return tiles
}
function kpiTileDetailParts(t) {
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
return { title: t.hoverTop || t.category, body }
}
/** KPI-Kacheln: Desktop — Hover (`title`). Touch — öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */
function BodyKpiOverview({ tiles }) {
const [touchUi, setTouchUi] = useState(false)
const [openKey, setOpenKey] = useState(null)
useEffect(() => {
const mq = window.matchMedia('(hover: none)')
const apply = () => setTouchUi(mq.matches)
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [])
useEffect(() => {
if (!openKey) return
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [openKey])
if (!tiles?.length) return null
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
const openParts = openTile ? kpiTileDetailParts(openTile) : null
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
{touchUi && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
Auf dem Smartphone: <strong></strong> für Erklärung und Details.
</div>
)}
<div className="body-kpi-overview">
{tiles.map(t => {
const accent = getStatusColor(t.status)
const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n')
return (
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
title={touchUi ? undefined : tip}
>
{touchUi && (
<button
type="button"
className="body-kpi-info-btn"
aria-label={`Details: ${t.category}`}
aria-expanded={openKey === t.key}
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
>
<Info size={16} strokeWidth={2.25} aria-hidden />
</button>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
{t.sublabel && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
)}
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
</div>
</div>
)
})}
</div>
{openParts && (
<div
className="body-kpi-touch-backdrop"
role="presentation"
onClick={() => setOpenKey(null)}
>
<div
className="body-kpi-touch-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="body-kpi-touch-title"
onClick={e => e.stopPropagation()}
>
<div className="body-kpi-touch-sheet__head">
<h3 id="body-kpi-touch-title" className="body-kpi-touch-sheet__title">{openParts.title}</h3>
<button type="button" className="body-kpi-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
×
</button>
</div>
{openParts.body ? (
<div className="body-kpi-touch-sheet__body">{openParts.body}</div>
) : (
<div className="body-kpi-touch-sheet__body body-kpi-touch-sheet__body--muted">Keine weiteren Details.</div>
)}
</div>
</div>
)}
</div>
)
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele',
pipeline:'🔬 Mehrstufige Analyse',
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese',
pipeline_goals:'Pipeline Ziele'}
return (
<div style={{marginTop:14}}>
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>🤖 KI-AUSWERTUNGEN</div>
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
{slugs.map(slug=>(
<button key={slug} className="btn btn-secondary" style={{fontSize:11,padding:'4px 8px'}}
onClick={()=>onRequest(slug)} disabled={loading===slug}>
{loading===slug
? <div className="spinner" style={{width:11,height:11}}/>
: <><Brain size={11}/> {LABELS[slug]||slug}</>}
</button>
))}
</div>
</div>
{relevant.length===0 && (
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,
fontSize:12,color:'var(--text3)'}}>
Noch keine Auswertung. Klicke oben um eine zu erstellen.
</div>
)}
{relevant.map(ins=>(
<div key={ins.id} style={{marginBottom:8,border:'1px solid var(--accent)33',borderRadius:10,overflow:'hidden'}}>
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
background:'var(--accent-light)66',cursor:'pointer'}}
onClick={()=>setExpanded(expanded===ins.id?null:ins.id)}>
<div style={{flex:1,fontSize:11,color:'var(--accent)',fontWeight:600}}>
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
</div>
{expanded===ins.id?<ChevronUp size={14}/>:<ChevronDown size={14}/>}
</div>
{expanded===ins.id && <div style={{padding:'12px 14px'}}><Markdown text={ins.content}/></div>}
</div>
))}
</div>
)
}
// ── Period selector ───────────────────────────────────────────────────────────
function PeriodSelector({ value, onChange }) {
const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}]
return (
<div style={{display:'flex',gap:4,marginBottom:12}}>
{opts.map(o=>(
<button key={o.v} onClick={()=>onChange(o.v)}
style={{padding:'4px 10px',borderRadius:12,fontSize:11,fontWeight:500,border:'1.5px solid',
cursor:'pointer',fontFamily:'var(--font)',
background:value===o.v?'var(--accent)':'transparent',
borderColor:value===o.v?'var(--accent)':'var(--border2)',
color:value===o.v?'white':'var(--text2)'}}>
{o.l}
</button>
))}
</div>
)
}
// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ──
function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(90)
const [groupedGoals, setGroupedGoals] = useState(null)
const [viz, setViz] = useState(null)
const [vizLoading, setVizLoading] = useState(true)
const [vizError, setVizError] = useState(null)
const sex = profile?.sex || 'm'
useEffect(() => {
let cancelled = false
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
setVizLoading(true)
setVizError(null)
api.getBodyHistoryViz(period)
.then(data => {
if (!cancelled) {
setViz(data)
setVizLoading(false)
}
})
.catch(e => {
if (!cancelled) {
setVizError(e.message || 'Laden fehlgeschlagen')
setVizLoading(false)
}
})
return () => { cancelled = true }
}, [period])
const w = viz?.weight
const cal = viz?.caliper
const circ = viz?.circumference
const summary = viz?.summary || {}
const wCd = (w?.series || []).map(row => ({
date: fmtDate(row.date),
weight: row.weight,
avg7: row.avg7,
avg14: row.avg14,
}))
const hasWeight = (w?.data_points || 0) >= 2
const avgAll = w?.overall_avg_kg
const minW = w?.min_kg
const maxW = w?.max_kg
const trendPeriods = w?.trend_periods || []
const bfCd = (cal?.series || []).map(s => ({
date: fmtDate(s.date),
bf: s.body_fat_pct,
}))
const propChartData = (circ?.proportion_series || []).map(p => ({
date: fmtDate(p.date),
vTaper: p.v_taper_cm,
vTaper_avg: p.v_taper_cm_avg,
belly: p.belly_cm,
}))
const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined)
const idxSeriesRaw = circ?.index_series || []
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
const idxOk = circ?.index_usable
const cirCd = (circ?.fallback_multiline || []).map(r => ({
date: fmtDate(r.date),
waist: r.waist,
hip: r.hip,
belly: r.belly,
}))
const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
const rules = (viz?.interpretation_tiles || []).map(t => ({
category: t.category,
icon: t.icon,
status: t.status,
title: t.title,
detail: t.detail,
value: t.value,
related_placeholder_keys: t.related_placeholder_keys,
}))
const kpiTiles = buildBodyKpiTiles({
summary,
rules,
trendPeriods,
minW,
maxW,
avgAll,
dataPoints: w?.data_points,
sex,
bfCat,
goalW,
})
const hasAnyData =
(w?.data_points > 0) ||
(cal?.data_points > 0) ||
(cirCd.length > 0)
if (vizLoading && !viz) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<div className="empty-state"><div className="spinner" /></div>
</div>
)
}
if (vizError) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
</div>
)
}
if (!hasAnyData) {
return (
<div>
<SectionHeader title="⚖️ Körper" />
<PeriodSelector value={period} onChange={setPeriod} />
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
</div>
)
}
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
<PeriodSelector value={period} onChange={setPeriod} />
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Aktivität</strong>.
</p>
{viz?.meta?.layer_2a_alignment && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
<BodyKpiOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
)}
{hasWeight && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {w?.data_points || 0} Einträge
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
Daten <ChevronRight size={10} />
</button>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(wCd.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{avgAll != null && (
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
)}
{goalW != null && (
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}kg`, fontSize: 9, fill: 'var(--accent)', position: 'right' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} name="weight" />
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} /> Täglich</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />Ø 7T</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>Ø 14T</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
</div>
</div>
)}
{bfCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
<NavToCaliper />
</div>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={bfCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={v => [`${v}%`, 'KF%']} />
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% zweite Kurve entfällt.</div>
</div>
)}
{propChartData.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
<strong>V-Taper (Brust Taille)</strong> in cm.
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
</div>
</div>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => {
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust Taille']
if (name === 'belly') return [`${v} cm`, 'Bauch']
return [v, name]
}}
/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
</ComposedChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust Taille</span>
<span><span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4" /></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
</div>
</div>
)}
{idxOk && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
</div>
<NavToCircum />
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={idxSeries} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
{idxSeries.some(d => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
{idxSeries.some(d => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
{idxSeries.some(d => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
</LineChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
</div>
</div>
)}
{propChartData.length < 2 && cirCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
{cirCd.some(d => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
</LineChart>
</ResponsiveContainer>
</div>
)}
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)
}
// ── Nutrition Section ─────────────────────────────────────────────────────────
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(30)
if (!nutrition?.length) return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
if (!filtN.length) return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
const n = filtN.length
const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n)
const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10
const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10
const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10
const latestW = weights?.[0]?.weight||80
const ptLow = Math.round(latestW*1.6)
const ptHigh = Math.round(latestW*2.2)
const proteinOk = avgProtein>=ptLow
// Stacked macro bar (daily)
const cdMacro = sorted.map(d=>({
date: fmtDate(d.date),
Protein: Math.round(d.protein_g||0),
KH: Math.round(d.carbs_g||0),
Fett: Math.round(d.fat_g||0),
kcal: Math.round(d.kcal||0),
}))
// Pie
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
const pieData = [
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
]
// Weekly macro bars
const weeklyMap={}
filtN.forEach(d=>{
const wk=dayjs(d.date).format('YYYY-WW')
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })()
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
})
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
label:w.label,
Protein:Math.round(w.protein/w.n),
KH:Math.round(w.carbs/w.n),
Fett:Math.round(w.fat/w.n),
kcal:Math.round(w.kcal/w.n),
}))
// Rules
const macroRules=[]
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein',
title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail:`1,62,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
value:avgProtein+'g'})
else macroRules.push({status:'good',icon:'🥩',category:'Protein',
title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'})
const protPct=Math.round(avgProtein*4/totalMacroKcal*100)
if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil',
title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`,
detail:`Empfehlung: 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
value:protPct+'%'})
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
{/* Stacked macro bars (daily) */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} {sorted[sorted.length-1]?.date?.slice(0,7)}
</div>
<ResponsiveContainer width="100%" height={170}>
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/>
</BarChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span>
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
</div>
</div>
{/* Pie + macro breakdown */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} {sorted[sorted.length-1]?.date?.slice(0,10)})
</div>
<div style={{display:'flex',alignItems:'center',gap:16}}>
<PieChart width={110} height={110}>
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
</Pie>
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
</PieChart>
<div style={{flex:1}}>
{pieData.map(p=>(
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}>
<div style={{width:10,height:10,borderRadius:2,background:p.color,flexShrink:0}}/>
<div style={{flex:1,fontSize:13}}>{p.name}</div>
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div>
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div>
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
</div>}
</div>
))}
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
Gesamt: {avgKcal} kcal/Tag
</div>
</div>
</div>
</div>
{/* Weekly stacked bars */}
{weeklyData.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
{/* New Nutrition Charts (Phase 0c) */}
<div style={{marginTop:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
<NutritionCharts days={period === 9999 ? 90 : period} />
</div>
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// ── Activity Section ──────────────────────────────────────────────────────────
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30)
if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
// Issue #31: Backend already filters by global quality level - only filter by period here
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
const byDate={}
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
const hrData =filtA.filter(a=>a.hr_avg)
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
const daysWithAct=new Set(filtA.map(a=>a.date)).size
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
const actRules=[{
status:consistency>=70?'good':consistency>=40?'warn':'bad',
icon:'📅', category:'Konsistenz',
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 45 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
value:consistency+'%'
}]
return (
<div>
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
{/* Issue #31: Show active global quality filter */}
{globalQualityLevel && globalQualityLevel !== 'all' && (
<div style={{
marginBottom:12, padding:'8px 12px', borderRadius:8,
background:'var(--surface2)', border:'1px solid var(--border)',
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
}}>
<span>
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'}
{globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'}
{globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'}
</span>
<a href="/settings" style={{
marginLeft:'auto', color:'var(--accent)', textDecoration:'none',
fontSize:11, fontWeight:500, whiteSpace:'nowrap'
}}>
Hier ändern
</a>
</div>
)}
<div style={{display:'flex',gap:6,marginBottom:12}}>
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={cd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(cd.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={v=>[`${v} kcal`]}/>
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
{topTypes.map(([type,count])=>(
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
<div style={{flex:1,fontSize:13}}>{type}</div>
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
</div>
))}
</div>
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
</div>
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// ── Correlation Section ───────────────────────────────────────────────────────
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
if (filtered.length < 5) return (
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
)
const sex = profile?.sex||'m'
const height = profile?.height||178
const latestW = filtered[filtered.length-1]?.weight||80
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
const tdee = Math.round(bmr*1.4) // light activity baseline
// Chart 1: Kcal vs Weight
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
// Chart 2: Protein vs Lean Mass (only days with both)
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
// Chart 3: Activity kcal vs Weight change
const actVsW = filtered.filter(d=>d.weight)
.map((d,i,arr)=>{
const prev = arr[i-1]
return {
date: fmtDate(d.date),
weight: d.weight,
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
kcal: d.kcal||0,
}
}).filter(d=>d.weightDelta!==null)
// Chart 4: Calorie balance (intake - estimated TDEE)
const balance = filtered.map(d=>({
date: fmtDate(d.date),
balance: Math.round((d.kcal||0) - tdee),
}))
const balWithAvg = rollingAvg(balance,'balance')
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length)
// ── Correlation insights ──
const corrInsights = []
// 1. Kcal → Weight correlation
if (filtered.length >= 14) {
const highKcal = filtered.filter(d=>d.kcal>tdee+200)
const lowKcal = filtered.filter(d=>d.kcal<tdee-200)
if (highKcal.length>=3 && lowKcal.length>=3) {
const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10
const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10
corrInsights.push({
icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn',
title: avgWLow < avgWHigh
? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss`
: `Kein klarer Kalorieneffekt auf Gewicht erkennbar`,
detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`,
})
}
}
// 2. Protein → Lean mass
if (protVsLean.length >= 3) {
const ptLow = Math.round(latestW*1.6)
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
if (highProt.length>=2 && lowProt.length>=2) {
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
corrInsights.push({
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn',
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`,
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`,
})
}
}
// 3. Avg balance
corrInsights.push({
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
avgBalance<-500?'Starkes Defizit Muskelerhalt durch ausreichend Protein sicherstellen.':
avgBalance<-100?'Moderates Defizit ideal für Fettabbau bei Muskelerhalt.':
avgBalance>300?'Kalorienüberschuss günstig für Muskelaufbau, Fettzunahme möglich.':
'Nahezu ausgeglichen Gewicht sollte stabil bleiben.'}`,
})
return (
<div>
<SectionHeader title="🔗 Korrelationen"/>
{/* Chart 1: Kcal vs Weight */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
📉 Kalorien (Ø 7T) vs. Gewicht
</div>
<ResponsiveContainer width="100%" height={190}>
<LineChart data={kcalVsW} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(kcalVsW.length/6)-1)}/>
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}> Kalorien</span> · <span style={{color:'#378ADD'}}> Gewicht</span>
</div>
</div>
{/* Chart 2: Calorie balance */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal)
</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
</div>
</div>
{/* Chart 3: Protein vs Lean Mass */}
{protVsLean.length >= 3 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
🥩 Protein vs. Magermasse
</div>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein"/>
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
<span style={{color:'#1D9E75'}}> Protein g/Tag</span> · <span style={{color:'#7F77DD'}}> Magermasse kg</span>
</div>
</div>
)}
{/* Correlation insights */}
{corrInsights.length > 0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
{corrInsights.map((item,i) => (
<div key={i} style={{padding:'10px 12px',borderRadius:8,marginBottom:6,
background:item.status==='good'?'var(--accent-light)':'var(--warn-bg)',
border:`1px solid ${item.status==='good'?'var(--accent)':'var(--warn)'}33`}}>
<div style={{display:'flex',gap:8,alignItems:'flex-start'}}>
<span style={{fontSize:16}}>{item.icon}</span>
<div>
<div style={{fontSize:13,fontWeight:600}}>{item.title}</div>
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>{item.detail}</div>
</div>
</div>
</div>
))}
<div style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
borderRadius:6,marginTop:6}}>
TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
</div>
</div>
)}
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
// ── Photo Grid ────────────────────────────────────────────────────────────────
function PhotoGrid() {
const [photos, setPhotos] = useState([])
const [big, setBig] = useState(null)
const load = () => api.listPhotos().then(setPhotos)
useEffect(() => {
load()
}, [])
const handleDelete = async (id) => {
if (!confirm('Dieses Foto löschen?')) return
try {
await api.deletePhoto(id)
if (big === id) setBig(null)
await load()
} catch (e) {
alert(e.message || 'Löschen fehlgeschlagen')
}
}
if (!photos.length) {
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
}
const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a)))
const byMonth = new Map()
for (const p of sorted) {
const mk = photoMonthKey(p)
if (!byMonth.has(mk)) byMonth.set(mk, [])
byMonth.get(mk).push(p)
}
const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a))
return (
<>
{big && (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.9)',
zIndex: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setBig(null)}
role="presentation"
>
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
</div>
)}
{monthKeys.map((mk) => (
<div key={mk} style={{ marginBottom: 16 }}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: 'var(--text3)',
marginBottom: 10,
textTransform: 'capitalize',
}}
>
{dayjs(`${mk}-01`).format('MMMM YYYY')}
</div>
<div className="photo-grid">
{byMonth.get(mk).map((p) => (
<div key={p.id} style={{ position: 'relative' }}>
<img
src={api.photoUrl(p.id)}
className="photo-thumb"
onClick={() => setBig(p.id)}
alt=""
/>
<div
style={{
position: 'absolute',
bottom: 4,
left: 4,
fontSize: 9,
background: 'rgba(0,0,0,0.6)',
color: 'white',
padding: '1px 4px',
borderRadius: 3,
}}
>
{formatPhotoCaption(p)}
</div>
<button
type="button"
className="btn btn-secondary"
style={{
position: 'absolute',
top: 4,
right: 4,
padding: 6,
minWidth: 0,
borderRadius: 8,
}}
title="Löschen"
onClick={(e) => {
e.stopPropagation()
handleDelete(p.id)
}}
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
</div>
))}
</>
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
// ── Recovery Section ──────────────────────────────────────────────────────────
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(28)
return (
<div>
<SectionHeader title="😴 Erholung & Vitalwerte" to="/vitals" toLabel="Daten"/>
<PeriodSelector value={period} onChange={setPeriod}/>
<div style={{marginBottom:12,fontSize:13,color:'var(--text2)',lineHeight:1.6}}>
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
</div>
{/* Recovery Charts (Phase 0c) */}
<RecoveryCharts days={period === 9999 ? 90 : period} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesundheit'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
)
}
const TABS = [
{ id:'body', label:'⚖️ Körper' },
{ id:'nutrition', label:'🍽️ Ernährung' },
{ id:'activity', label:'🏋️ Aktivität' },
{ id:'recovery', label:'😴 Erholung' },
{ id:'correlation', label:'🔗 Korrelation' },
{ id:'photos', label:'📷 Fotos' },
]
export default function History() {
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
const location = useLocation?.() || {}
const [tab, setTab] = useState((location.state?.tab)||'body')
const [weights, setWeights] = useState([])
const [calipers, setCalipers] = useState([])
const [circs, setCircs] = useState([])
const [nutrition, setNutrition] = useState([])
const [activities, setActivities] = useState([])
const [corrData, setCorrData] = useState([])
const [insights, setInsights] = useState([])
const [prompts, setPrompts] = useState([])
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
const [loadingSlug,setLoadingSlug]= useState(null)
const loadAll = () => Promise.all([
api.listWeight(365), api.listCaliper(), api.listCirc(),
api.listNutrition(90), api.listActivity(25_000),
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
api.listPrompts(),
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
setWeights(w); setCalipers(ca); setCircs(ci)
setNutrition(n); setActivities(a); setCorrData(corr)
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
setPrompts(Array.isArray(pr)?pr:[])
setLoading(false)
})
useEffect(() => {
loadAll()
}, [activeProfile?.quality_filter_level])
useEffect(() => {
const t = location.state?.tab
if (t && TABS.some(x => x.id === t)) setTab(t)
}, [location.state?.tab])
const requestInsight = async (slug) => {
setLoadingSlug(slug)
try {
const result = await api.runInsight(slug)
// result is already JSON, not a Response object
const ins = await api.latestInsights()
setInsights(Array.isArray(ins)?ins:[])
} catch(e){
alert('KI-Fehler: '+e.message)
}
finally{ setLoadingSlug(null) }
}
if(loading) return <div className="empty-state"><div className="spinner"/></div>
// Filter active prompts
const activeSlugs = prompts.filter(p=>p.active).map(p=>p.slug)
const filterActiveSlugs = (slugs) => slugs.filter(s=>activeSlugs.includes(s))
const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs}
return (
<div className="history-page">
<h1 className="page-title history-page__title">Verlauf & Auswertung</h1>
<div className="history-page__layout">
<nav className="history-tabs" aria-label="Verlauf-Kategorien">
<div className="history-tabs__scroller">
{TABS.map(t => (
<button
key={t.id}
type="button"
className={`history-tab-btn${tab === t.id ? ' history-tab-btn--active' : ''}`}
onClick={() => setTab(t.id)}
aria-current={tab === t.id ? 'page' : undefined}
>
{t.label}
</button>
))}
</div>
</nav>
<div className="history-content">
{tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
{tab==='photos' && <PhotoGrid/>}
</div>
</div>
</div>
)
}