2026-03-16 13:35:11 +01:00
import { useState , useEffect } from 'react'
import { useNavigate , useLocation } from 'react-router-dom'
2026-03-23 22:29:49 +01:00
import { useProfile } from '../context/ProfileContext'
2026-03-16 13:35:11 +01:00
import {
LineChart , Line , BarChart , Bar ,
XAxis , YAxis , Tooltip , ResponsiveContainer , CartesianGrid ,
2026-04-19 15:55:46 +02:00
ReferenceLine , PieChart , Pie , Cell , ComposedChart
2026-03-16 13:35:11 +01:00
} from 'recharts'
2026-04-19 16:46:05 +02:00
import { ChevronRight , Brain , ChevronDown , ChevronUp , Trash2 , Info } from 'lucide-react'
2026-03-16 13:35:11 +01:00
import { api } from '../utils/api'
2026-04-19 10:13:22 +02:00
import { photoMonthKey , photoSortKey , formatPhotoCaption } from '../utils/photoDisplay'
2026-04-19 16:06:07 +02:00
import { getBfCategory } from '../utils/calc'
import { getStatusColor , getStatusBg } from '../utils/interpret'
2026-03-16 13:35:11 +01:00
import Markdown from '../utils/Markdown'
2026-03-21 16:56:35 +01:00
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
2026-03-29 07:02:54 +02:00
import NutritionCharts from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
2026-03-16 13:35:11 +01:00
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 >
)
}
2026-04-19 16:21:37 +02:00
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' ,
2026-04-19 16:27:59 +02:00
category : 'Fettverteilung' ,
2026-04-19 16:21:37 +02:00
icon : '📐' ,
value : String ( summary . whr ) ,
2026-04-19 16:27:59 +02:00
sublabel : 'WHR · Taille ÷ Hüfte' ,
2026-04-19 16:21:37 +02:00
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' ,
2026-04-19 16:27:59 +02:00
category : 'Taille/Größe' ,
2026-04-19 16:21:37 +02:00
icon : '📏' ,
value : String ( summary . whtr ) ,
2026-04-19 16:27:59 +02:00
sublabel : 'WHtR · Taille ÷ Größe' ,
2026-04-19 16:21:37 +02:00
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
}
2026-04-19 16:46:05 +02:00
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). */
2026-04-19 16:21:37 +02:00
function BodyKpiOverview ( { tiles } ) {
2026-04-19 16:46:05 +02:00
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 ] )
2026-04-19 16:21:37 +02:00
if ( ! tiles ? . length ) return null
2026-04-19 16:46:05 +02:00
const openTile = openKey ? tiles . find ( x => x . key === openKey ) : null
const openParts = openTile ? kpiTileDetailParts ( openTile ) : null
2026-04-19 15:55:46 +02:00
return (
< div style = { { marginBottom : 12 } } >
2026-04-19 16:21:37 +02:00
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 } } > Kennzahlen < / div >
2026-04-19 16:46:05 +02:00
{ 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 >
) }
2026-04-19 16:21:37 +02:00
< 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' )
2026-04-19 15:55:46 +02:00
return (
2026-04-19 16:21:37 +02:00
< div
key = { t . key }
className = "body-kpi-card"
2026-04-19 16:46:05 +02:00
style = { { borderLeft : ` 4px solid ${ accent } ` , position : 'relative' } }
title = { touchUi ? undefined : tip }
2026-04-19 15:55:46 +02:00
>
2026-04-19 16:46:05 +02:00
{ 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 } } >
2026-04-19 16:21:37 +02:00
< span style = { { fontSize : 14 , lineHeight : 1 } } > { t . icon } < / span >
2026-04-19 15:55:46 +02:00
< div style = { { flex : 1 , minWidth : 0 } } >
2026-04-19 16:21:37 +02:00
< 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 >
2026-04-19 15:55:46 +02:00
) }
< / div >
2026-04-19 16:21:37 +02:00
< div style = { { textAlign : 'right' , flexShrink : 0 } } >
< div style = { { fontSize : 10 , fontWeight : 700 , color : accent , lineHeight : 1.2 } } > { t . verdict } < / div >
< / div >
2026-04-19 15:55:46 +02:00
< / div >
2026-04-19 16:21:37 +02:00
< / div >
2026-04-19 15:55:46 +02:00
)
} ) }
< / div >
2026-04-19 16:46:05 +02:00
{ 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 >
) }
2026-04-19 15:55:46 +02:00
< / 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 >
)
}
2026-03-16 13:35:11 +01:00
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 >
)
}
2026-04-19 16:06:07 +02:00
// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ──
function BodySection ( { profile , insights , onRequest , loadingSlug , filterActiveSlugs } ) {
2026-03-16 13:35:11 +01:00
const [ period , setPeriod ] = useState ( 90 )
2026-04-19 15:55:46 +02:00
const [ groupedGoals , setGroupedGoals ] = useState ( null )
2026-04-19 16:06:07 +02:00
const [ viz , setViz ] = useState ( null )
const [ vizLoading , setVizLoading ] = useState ( true )
const [ vizError , setVizError ] = useState ( null )
const sex = profile ? . sex || 'm'
2026-03-16 13:35:11 +01:00
2026-04-19 15:55:46 +02:00
useEffect ( ( ) => {
let cancelled = false
api . listGoalsGrouped ( )
. then ( g => { if ( ! cancelled ) setGroupedGoals ( g ) } )
. catch ( ( ) => { if ( ! cancelled ) setGroupedGoals ( { } ) } )
return ( ) => { cancelled = true }
} , [ ] )
2026-04-19 16:06:07 +02:00
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 ,
} ) )
2026-03-16 13:35:11 +01:00
2026-04-19 16:06:07 +02:00
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 )
2026-03-16 13:35:11 +01:00
2026-04-19 16:06:07 +02:00
const idxSeriesRaw = circ ? . index _series || [ ]
const idxSeries = idxSeriesRaw . map ( row => ( { ... row , date : fmtDate ( row . date ) } ) )
const idxOk = circ ? . index _usable
2026-03-16 13:35:11 +01:00
2026-04-19 16:06:07 +02:00
const cirCd = ( circ ? . fallback _multiline || [ ] ) . map ( r => ( {
date : fmtDate ( r . date ) ,
waist : r . waist ,
hip : r . hip ,
belly : r . belly ,
2026-03-16 13:35:11 +01:00
} ) )
2026-04-19 16:06:07 +02:00
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 ,
2026-03-16 13:35:11 +01:00
} ) )
2026-04-19 16:21:37 +02:00
const kpiTiles = buildBodyKpiTiles ( {
summary ,
rules ,
trendPeriods ,
minW ,
maxW ,
avgAll ,
dataPoints : w ? . data _points ,
sex ,
bfCat ,
goalW ,
} )
2026-04-19 16:06:07 +02:00
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 >
)
2026-04-19 15:55:46 +02:00
}
2026-04-19 16:06:07 +02:00
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 >
)
2026-03-16 13:35:11 +01:00
}
return (
< div >
2026-04-19 16:06:07 +02:00
< SectionHeader title = "⚖️ Körper" lastUpdated = { viz ? . last _updated } / >
< PeriodSelector value = { period } onChange = { setPeriod } / >
2026-03-16 13:35:11 +01:00
2026-04-19 15:55:46 +02:00
< BodyGoalsStrip grouped = { groupedGoals } / >
2026-04-19 16:21:37 +02:00
< 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 > .
2026-04-19 15:55:46 +02:00
< / p >
2026-04-19 16:06:07 +02:00
{ viz ? . meta ? . layer _2a _alignment && (
2026-04-19 16:21:37 +02:00
< div style = { { fontSize : 10 , color : 'var(--text3)' , marginBottom : 8 , lineHeight : 1.4 } } >
2026-04-19 16:06:07 +02:00
{ viz . meta . layer _2a _alignment }
< / div >
) }
2026-04-19 16:21:37 +02:00
< BodyKpiOverview tiles = { kpiTiles } / >
2026-03-16 13:35:11 +01:00
2026-04-19 16:06:07 +02:00
{ vizLoading && (
< div style = { { fontSize : 11 , color : 'var(--text3)' , marginBottom : 8 } } > Aktualisiere … < / div >
) }
2026-03-16 13:35:11 +01:00
{ hasWeight && (
2026-04-19 16:06:07 +02:00
< 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
2026-03-16 13:35:11 +01:00
< / div >
2026-04-19 16:06:07 +02:00
< button type = "button" className = "btn btn-secondary" style = { { fontSize : 10 , padding : '2px 8px' } } onClick = { ( ) => { window . location . href = '/weight' } } >
Daten < ChevronRight size = { 10 } / >
2026-03-16 13:35:11 +01:00
< / button >
< / div >
< ResponsiveContainer width = "100%" height = { 200 } >
2026-04-19 16:06:07 +02:00
< 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" / >
2026-03-16 13:35:11 +01:00
< / LineChart >
< / ResponsiveContainer >
2026-04-19 16:06:07 +02:00
< 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 } } / > Ø 7 T < / 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 > Ø 14 T < / span >
< span > < span style = { { display : 'inline-block' , width : 12 , height : 2 , background : 'var(--text3)' , verticalAlign : 'middle' , marginRight : 3 , borderTop : '2px dashed' } } / > Ø Gesamt < / span >
2026-03-16 13:35:11 +01:00
< / div >
< / div >
) }
2026-04-19 16:06:07 +02:00
{ 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 / >
2026-03-16 13:35:11 +01:00
< / div >
< ResponsiveContainer width = "100%" height = { 170 } >
2026-04-19 16:06:07 +02:00
< 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" / >
2026-03-16 13:35:11 +01:00
< / LineChart >
< / ResponsiveContainer >
2026-04-19 16:06:07 +02:00
< div style = { { fontSize : 10 , color : 'var(--text3)' , marginTop : 6 , lineHeight : 1.4 } } > Magermasse aus Gewicht und KF % — zweite Kurve entfällt . < / div >
2026-04-19 15:55:46 +02:00
< / div >
) }
{ propChartData . length >= 2 && (
2026-04-19 16:06:07 +02:00
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'flex-start' , marginBottom : 8 , gap : 8 } } >
2026-04-19 15:55:46 +02:00
< div >
2026-04-19 16:06:07 +02:00
< 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 ) . < / > }
2026-04-19 15:55:46 +02:00
< / div >
< / div >
2026-04-19 16:06:07 +02:00
< NavToCircum / >
2026-04-19 15:55:46 +02:00
< / div >
< ResponsiveContainer width = "100%" height = { 200 } >
2026-04-19 16:06:07 +02:00
< 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 } }
2026-04-19 15:55:46 +02:00
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 ]
2026-04-19 16:06:07 +02:00
} }
/ >
< 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" / > }
2026-04-19 15:55:46 +02:00
< / ComposedChart >
< / ResponsiveContainer >
2026-04-19 16:06:07 +02:00
< 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 > }
2026-04-19 15:55:46 +02:00
< / div >
< / div >
) }
{ idxOk && (
2026-04-19 16:06:07 +02:00
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 8 } } >
2026-04-19 15:55:46 +02:00
< div >
2026-04-19 16:06:07 +02:00
< 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 >
2026-04-19 15:55:46 +02:00
< / div >
2026-04-19 16:06:07 +02:00
< NavToCircum / >
2026-04-19 15:55:46 +02:00
< / div >
< ResponsiveContainer width = "100%" height = { 180 } >
2026-04-19 16:06:07 +02:00
< 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" / > }
2026-04-19 15:55:46 +02:00
< / LineChart >
< / ResponsiveContainer >
2026-04-19 16:06:07 +02:00
< 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 >
2026-03-16 13:35:11 +01:00
< / div >
< / div >
) }
2026-04-19 16:06:07 +02:00
{ 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 / >
2026-04-19 15:55:46 +02:00
< / div >
2026-04-19 16:06:07 +02:00
< div style = { { fontSize : 10 , color : 'var(--text3)' , marginBottom : 8 , lineHeight : 1.4 } } > Mit Brust - und Taillenumfang erscheint die Proportionen - Ansicht oben . < / div >
2026-03-16 13:35:11 +01:00
< ResponsiveContainer width = "100%" height = { 150 } >
2026-04-19 16:06:07 +02:00
< 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" / > }
2026-03-16 13:35:11 +01:00
< / LineChart >
< / ResponsiveContainer >
< / div >
) }
2026-04-19 16:06:07 +02:00
< InsightBox insights = { insights } slugs = { filterActiveSlugs ( [ 'pipeline' , 'koerper' , 'gesundheit' , 'ziele' ] ) } onRequest = { onRequest } loading = { loadingSlug } / >
2026-03-16 13:35:11 +01:00
< / div >
)
}
// ── Nutrition Section ─────────────────────────────────────────────────────────
2026-03-19 07:47:59 +01:00
function NutritionSection ( { nutrition , weights , profile , insights , onRequest , loadingSlug , filterActiveSlugs } ) {
2026-03-16 13:35:11 +01:00
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,6– 2,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: 25– 35%. 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 >
2026-03-29 07:02:54 +02:00
{ /* 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 >
2026-03-19 07:47:59 +01:00
< InsightBox insights = { insights } slugs = { filterActiveSlugs ( [ 'ernaehrung' ] ) } onRequest = { onRequest } loading = { loadingSlug } / >
2026-03-16 13:35:11 +01:00
< / div >
)
}
// ── Activity Section ──────────────────────────────────────────────────────────
2026-03-23 22:29:49 +01:00
function ActivitySection ( { activities , insights , onRequest , loadingSlug , filterActiveSlugs , globalQualityLevel } ) {
2026-03-16 13:35:11 +01:00
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' )
2026-03-23 22:04:29 +01:00
2026-03-24 08:06:20 +01:00
// Issue #31: Backend already filters by global quality level - only filter by period here
const filtA = activities . filter ( d => period === 9999 || d . date >= cutoff )
2026-03-16 13:35:11 +01:00
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: 4– 5 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 } / >
2026-03-23 21:59:02 +01:00
2026-03-24 08:06:20 +01:00
{ /* 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 >
2026-03-23 22:04:29 +01:00
< / div >
2026-03-24 08:06:20 +01:00
) }
2026-03-23 21:59:02 +01:00
2026-03-16 13:35:11 +01:00
< 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 >
2026-03-21 16:56:35 +01:00
< 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 >
2026-03-16 13:35:11 +01:00
< 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 >
2026-03-19 07:47:59 +01:00
< InsightBox insights = { insights } slugs = { filterActiveSlugs ( [ 'aktivitaet' ] ) } onRequest = { onRequest } loading = { loadingSlug } / >
2026-03-16 13:35:11 +01:00
< / div >
)
}
// ── Correlation Section ───────────────────────────────────────────────────────
2026-03-19 07:47:59 +01:00
function CorrelationSection ( { corrData , insights , profile , onRequest , loadingSlug , filterActiveSlugs } ) {
2026-03-16 13:35:11 +01:00
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 ( Ø 7 T ) 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 >
) }
2026-03-19 07:47:59 +01:00
< InsightBox insights = { insights } slugs = { filterActiveSlugs ( [ 'gesamt' , 'ziele' ] ) } onRequest = { onRequest } loading = { loadingSlug } / >
2026-03-16 13:35:11 +01:00
< / div >
)
}
// ── Photo Grid ────────────────────────────────────────────────────────────────
function PhotoGrid ( ) {
2026-04-19 09:20:28 +02:00
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" / >
}
2026-04-19 10:13:22 +02:00
const sorted = [ ... photos ] . sort ( ( a , b ) => photoSortKey ( b ) . localeCompare ( photoSortKey ( a ) ) )
2026-04-19 09:20:28 +02:00
const byMonth = new Map ( )
for ( const p of sorted ) {
2026-04-19 10:13:22 +02:00
const mk = photoMonthKey ( p )
2026-04-19 09:20:28 +02:00
if ( ! byMonth . has ( mk ) ) byMonth . set ( mk , [ ] )
byMonth . get ( mk ) . push ( p )
}
const monthKeys = [ ... byMonth . keys ( ) ] . sort ( ( a , b ) => b . localeCompare ( a ) )
2026-03-16 13:35:11 +01:00
return (
< >
2026-04-19 09:20:28 +02:00
{ 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' ) }
2026-03-16 13:35:11 +01:00
< / div >
2026-04-19 09:20:28 +02:00
< 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 ,
} }
>
2026-04-19 10:13:22 +02:00
{ formatPhotoCaption ( p ) }
2026-04-19 09:20:28 +02:00
< / 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 >
) ) }
2026-03-16 13:35:11 +01:00
< / >
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
2026-03-29 07:02:54 +02:00
// ── 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 >
)
}
2026-03-16 13:35:11 +01:00
const TABS = [
{ id : 'body' , label : '⚖️ Körper' } ,
{ id : 'nutrition' , label : '🍽️ Ernährung' } ,
{ id : 'activity' , label : '🏋️ Aktivität' } ,
2026-03-29 07:02:54 +02:00
{ id : 'recovery' , label : '😴 Erholung' } ,
2026-03-16 13:35:11 +01:00
{ id : 'correlation' , label : '🔗 Korrelation' } ,
{ id : 'photos' , label : '📷 Fotos' } ,
]
export default function History ( ) {
2026-03-23 22:29:49 +01:00
const { activeProfile } = useProfile ( ) // Issue #31: Get global quality filter
2026-03-16 13:35:11 +01:00
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 ( [ ] )
2026-03-19 07:47:59 +01:00
const [ prompts , setPrompts ] = useState ( [ ] )
2026-03-16 13:35:11 +01:00
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 ( ) ,
2026-04-07 12:28:59 +02:00
api . listNutrition ( 90 ) , api . listActivity ( 25 _000 ) ,
2026-03-16 13:35:11 +01:00
api . nutritionCorrelations ( ) , api . latestInsights ( ) , api . getProfile ( ) ,
2026-03-19 07:47:59 +01:00
api . listPrompts ( ) ,
] ) . then ( ( [ w , ca , ci , n , a , corr , ins , p , pr ] ) => {
2026-03-16 13:35:11 +01:00
setWeights ( w ) ; setCalipers ( ca ) ; setCircs ( ci )
setNutrition ( n ) ; setActivities ( a ) ; setCorrData ( corr )
setInsights ( Array . isArray ( ins ) ? ins : [ ] ) ; setProfile ( p )
2026-03-19 07:47:59 +01:00
setPrompts ( Array . isArray ( pr ) ? pr : [ ] )
2026-03-16 13:35:11 +01:00
setLoading ( false )
} )
2026-04-07 12:28:59 +02:00
useEffect ( ( ) => {
loadAll ( )
} , [ activeProfile ? . quality _filter _level ] )
2026-03-16 13:35:11 +01:00
2026-04-05 08:46:37 +02:00
useEffect ( ( ) => {
const t = location . state ? . tab
if ( t && TABS . some ( x => x . id === t ) ) setTab ( t )
} , [ location . state ? . tab ] )
2026-03-16 13:35:11 +01:00
const requestInsight = async ( slug ) => {
setLoadingSlug ( slug )
try {
2026-03-19 07:47:59 +01:00
const result = await api . runInsight ( slug )
// result is already JSON, not a Response object
const ins = await api . latestInsights ( )
2026-03-16 13:35:11 +01:00
setInsights ( Array . isArray ( ins ) ? ins : [ ] )
2026-03-19 07:47:59 +01:00
} catch ( e ) {
alert ( 'KI-Fehler: ' + e . message )
}
2026-03-16 13:35:11 +01:00
finally { setLoadingSlug ( null ) }
}
if ( loading ) return < div className = "empty-state" > < div className = "spinner" / > < / div >
2026-03-19 07:47:59 +01:00
// 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 }
2026-03-16 13:35:11 +01:00
return (
2026-04-05 08:46:37 +02:00
< 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" >
2026-04-19 16:06:07 +02:00
{ tab === 'body' && < BodySection profile = { profile } { ...sp } / > }
2026-04-05 08:46:37 +02:00
{ 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 >
2026-03-16 13:35:11 +01:00
< / div >
< / div >
)
}