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 17:00:05 +02:00
import { ChevronRight , Brain , ChevronDown , ChevronUp , Trash2 } 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-04-19 17:28:41 +02:00
import { MACRO _CHART , macroFillByName , NUTRITION _MACRO _CHART _BLOCK _PX } from '../utils/macroChartTheme'
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-04-19 17:13:59 +02:00
import NutritionCharts , { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
2026-03-29 07:02:54 +02:00
import RecoveryCharts from '../components/RecoveryCharts'
2026-04-19 17:00:05 +02:00
import KpiTilesOverview from '../components/KpiTilesOverview'
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 17:13:59 +02:00
function NutritionGoalsStrip ( { grouped } ) {
const nav = useNavigate ( )
const goals = ( grouped ? . nutrition || [ ] ) . 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)' } } > Ernährungsbezogene 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-04-19 15:55:46 +02:00
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' ,
2026-04-19 21:27:12 +02:00
aktivitaet : 'Fitness' , gesundheit : 'Gesundheit' , ziele : 'Ziele' ,
2026-03-16 13:35:11 +01:00
pipeline : '🔬 Mehrstufige Analyse' ,
pipeline _body : 'Pipeline Körper' , pipeline _nutrition : 'Pipeline Ernährung' ,
2026-04-19 21:27:12 +02:00
pipeline _activity : 'Pipeline Fitness' , pipeline _synthesis : 'Pipeline Synthese' ,
2026-03-16 13:35:11 +01:00
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 } } >
2026-04-19 21:27:12 +02:00
Daten und Kennzahlen aus dem Backend - Bundle ( gleiche Quelle wie Platzhalter ) . Training : < strong > Verlauf → Fitness < / 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 17:00:05 +02:00
< KpiTilesOverview 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 >
)
}
2026-04-19 17:43:29 +02:00
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
function kcalVsWeightKcalDomain ( points , tdeeRef ) {
const vals = ( points || [ ] )
. map ( d => Number ( d . kcal _avg ) )
. filter ( v => ! Number . isNaN ( v ) )
if ( ! vals . length ) return [ 'auto' , 'auto' ]
let lo = Math . min ( ... vals )
let hi = Math . max ( ... vals )
const t = tdeeRef != null ? Number ( tdeeRef ) : NaN
if ( ! Number . isNaN ( t ) ) {
lo = Math . min ( lo , t )
hi = Math . max ( hi , t )
}
const span = hi - lo || 400
const pad = Math . max ( 100 , span * 0.1 )
return [ Math . max ( 0 , Math . floor ( lo - pad ) ) , Math . ceil ( hi + pad ) ]
}
const TDEE _REF _LINE _COLOR = '#475569'
2026-04-19 17:36:45 +02:00
/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */
function KcalVsWeightLegend ( { showTdee } ) {
const line = ( color ) => ( {
display : 'inline-block' ,
width : 22 ,
height : 3 ,
background : color ,
borderRadius : 1 ,
verticalAlign : 'middle' ,
marginRight : 6 ,
} )
return (
< div
className = "kcal-vs-weight-legend"
style = { {
display : 'flex' ,
flexWrap : 'wrap' ,
justifyContent : 'center' ,
alignItems : 'center' ,
gap : '12px 18px' ,
marginTop : 10 ,
fontSize : 10 ,
color : 'var(--text2)' ,
lineHeight : 1.35 ,
} }
>
< span style = { { display : 'inline-flex' , alignItems : 'center' } } >
< span style = { line ( '#EA580C' ) } / >
Ø Kalorien ( 7 - Tage - Mittel )
< / span >
< span style = { { display : 'inline-flex' , alignItems : 'center' } } >
< span
style = { {
display : 'inline-block' ,
width : 9 ,
height : 9 ,
borderRadius : '50%' ,
background : '#2563EB' ,
marginRight : 6 ,
verticalAlign : 'middle' ,
} }
/ >
Gewicht ( kg )
< / span >
{ showTdee ? (
< span style = { { display : 'inline-flex' , alignItems : 'center' } } >
< span
style = { {
display : 'inline-block' ,
width : 22 ,
height : 0 ,
verticalAlign : 'middle' ,
marginRight : 6 ,
2026-04-19 17:43:29 +02:00
borderTop : ` 2px dashed ${ TDEE _REF _LINE _COLOR } ` ,
2026-04-19 17:36:45 +02:00
opacity : 0.95 ,
} }
/ >
TDEE - Referenz ( geschätzt )
< / span >
) : null }
< / div >
)
}
2026-04-19 17:20:24 +02:00
/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */
function KcalVsWeightChart ( { vizKcalWeight , corrData : corrRows , profile , cutoffDate , allTime } ) {
if ( vizKcalWeight ? . points ? . length >= 5 ) {
const tdee = vizKcalWeight . tdee _reference _kcal
const kcalVsW = vizKcalWeight . points . map ( d => ( {
... d ,
date : fmtDate ( d . date ) ,
} ) )
const n = vizKcalWeight . common _days _count ? ? kcalVsW . length
const tdeeLabel = tdee != null && tdee > 0 ? Math . round ( tdee ) : null
2026-04-19 17:43:29 +02:00
const kcalDomain = kcalVsWeightKcalDomain ( kcalVsW , tdeeLabel )
2026-04-19 17:20:24 +02:00
return (
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 6 } } >
Kalorien ( Ø 7 Tage ) vs . Gewicht
< / div >
< div style = { { fontSize : 10 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 8 } } >
2026-04-19 17:36:45 +02:00
Nur Tage mit Kalorien - und Gewichtsdaten . Linke Achse : kcal ( Ø 7 Tage ) , rechte Achse : kg .
2026-04-19 17:20:24 +02:00
< / div >
< ResponsiveContainer width = "100%" height = { 200 } >
< 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 ) } / >
2026-04-19 17:43:29 +02:00
< YAxis yAxisId = "kcal" tick = { { fontSize : 9 , fill : 'var(--text3)' } } tickLine = { false } domain = { kcalDomain } / >
2026-04-19 17:20:24 +02:00
< 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' ] }
/ >
{ tdeeLabel != null && (
2026-04-19 17:43:29 +02:00
< ReferenceLine
yAxisId = "kcal"
y = { tdeeLabel }
stroke = { TDEE _REF _LINE _COLOR }
strokeDasharray = "6 5"
strokeWidth = { 2 }
isFront
/ >
2026-04-19 17:20:24 +02:00
) }
< Line yAxisId = "kcal" type = "monotone" dataKey = "kcal_avg" stroke = "#EA580C" strokeWidth = { 2.5 } dot = { false } name = "kcal_avg" / >
< Line yAxisId = "weight" type = "monotone" dataKey = "weight" stroke = "#2563EB" strokeWidth = { 2.5 } dot = { { r : 2 , fill : '#2563EB' } } name = "weight" / >
< / LineChart >
< / ResponsiveContainer >
2026-04-19 17:36:45 +02:00
< KcalVsWeightLegend showTdee = { tdeeLabel != null } / >
< div style = { { fontSize : 10 , color : 'var(--text3)' , textAlign : 'center' , marginTop : 8 } } >
2026-04-19 17:20:24 +02:00
{ tdeeLabel != null
2026-04-19 17:36:45 +02:00
? ` TDEE ~ ${ tdeeLabel } kcal · ${ n } gemeinsame Tage `
2026-04-19 17:20:24 +02:00
: ` Keine TDEE-Referenz (Gewicht/Demografie) · ${ n } gemeinsame Tage ` }
< / div >
< / div >
)
}
2026-03-16 13:35:11 +01:00
2026-04-19 17:13:59 +02:00
const raw = ( corrRows || [ ] ) . filter ( d => {
if ( ! d . kcal || d . weight == null ) return false
const ds = typeof d . date === 'string' ? d . date . slice ( 0 , 10 ) : dayjs ( d . date ) . format ( 'YYYY-MM-DD' )
return allTime || ds >= cutoffDate
} )
if ( raw . length < 5 ) return null
const sex = profile ? . sex || 'm'
const height = profile ? . height || 178
const latestW = raw [ raw . 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 )
const kcalVsW = rollingAvg ( raw . map ( d => ( { ... d , date : fmtDate ( d . date ) } ) ) , 'kcal' )
2026-04-19 17:43:29 +02:00
const kcalDomainFb = kcalVsWeightKcalDomain ( kcalVsW , tdee )
2026-04-19 17:13:59 +02:00
return (
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 6 } } >
Kalorien ( Ø 7 Tage ) vs . Gewicht
< / div >
< div style = { { fontSize : 10 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 8 } } >
2026-04-19 17:36:45 +02:00
Nur Tage mit Kalorien - und Gewichtsdaten . Linke Achse : kcal ( Ø 7 Tage ) , rechte Achse : kg .
2026-04-19 17:13:59 +02:00
< / div >
< ResponsiveContainer width = "100%" height = { 200 } >
< 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 ) } / >
2026-04-19 17:43:29 +02:00
< YAxis yAxisId = "kcal" tick = { { fontSize : 9 , fill : 'var(--text3)' } } tickLine = { false } domain = { kcalDomainFb } / >
2026-04-19 17:13:59 +02:00
< 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' ] }
/ >
2026-04-19 17:43:29 +02:00
< ReferenceLine
yAxisId = "kcal"
y = { tdee }
stroke = { TDEE _REF _LINE _COLOR }
strokeDasharray = "6 5"
strokeWidth = { 2 }
isFront
/ >
2026-04-19 17:13:59 +02:00
< Line yAxisId = "kcal" type = "monotone" dataKey = "kcal_avg" stroke = "#EA580C" strokeWidth = { 2.5 } dot = { false } name = "kcal_avg" / >
< Line yAxisId = "weight" type = "monotone" dataKey = "weight" stroke = "#2563EB" strokeWidth = { 2.5 } dot = { { r : 2 , fill : '#2563EB' } } name = "weight" / >
< / LineChart >
< / ResponsiveContainer >
2026-04-19 17:36:45 +02:00
< KcalVsWeightLegend showTdee / >
< div style = { { fontSize : 10 , color : 'var(--text3)' , textAlign : 'center' , marginTop : 8 } } >
TDEE ~ { tdee } kcal ( Fallback Mifflin × 1 , 4 ) · { raw . length } gemeinsame Tage
2026-04-19 17:13:59 +02:00
< / div >
2026-03-16 13:35:11 +01:00
< / div >
)
2026-04-19 17:13:59 +02:00
}
// ── Nutrition Section ─────────────────────────────────────────────────────────
2026-04-19 17:20:24 +02:00
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
function NutritionSection ( { profile , insights , onRequest , loadingSlug , filterActiveSlugs } ) {
2026-04-19 17:13:59 +02:00
const [ period , setPeriod ] = useState ( 30 )
const [ groupedGoals , setGroupedGoals ] = useState ( null )
2026-04-19 17:20:24 +02:00
const [ viz , setViz ] = useState ( null )
const [ vizLoad , setVizLoad ] = useState ( true )
const [ vizErr , setVizErr ] = useState ( null )
2026-04-19 17:13:59 +02:00
useEffect ( ( ) => {
let cancelled = false
api . listGoalsGrouped ( )
. then ( g => { if ( ! cancelled ) setGroupedGoals ( g ) } )
. catch ( ( ) => { if ( ! cancelled ) setGroupedGoals ( { } ) } )
return ( ) => { cancelled = true }
} , [ ] )
useEffect ( ( ) => {
let cancelled = false
2026-04-19 17:20:24 +02:00
setViz ( null )
setVizLoad ( true )
setVizErr ( null )
const daysReq = period === 9999 ? 9999 : period
api . getNutritionHistoryViz ( daysReq )
. then ( v => { if ( ! cancelled ) setViz ( v ) } )
. catch ( e => { if ( ! cancelled ) setVizErr ( e . message || 'Laden fehlgeschlagen' ) } )
. finally ( ( ) => { if ( ! cancelled ) setVizLoad ( false ) } )
2026-04-19 17:13:59 +02:00
return ( ) => { cancelled = true }
2026-04-19 17:20:24 +02:00
} , [ period ] )
2026-04-19 17:13:59 +02:00
2026-04-19 17:20:24 +02:00
if ( vizLoad ) {
2026-04-19 17:13:59 +02:00
return (
2026-04-19 17:20:24 +02:00
< div >
< SectionHeader title = "🍽️ Ernährung" to = "/nutrition" toLabel = "Import" / >
< PeriodSelector value = { period } onChange = { setPeriod } / >
< div className = "spinner" style = { { margin : 24 } } / >
< / div >
2026-04-19 17:13:59 +02:00
)
}
2026-04-19 17:20:24 +02:00
if ( vizErr ) {
2026-04-19 17:13:59 +02:00
return (
< div >
2026-04-19 17:20:24 +02:00
< SectionHeader title = "🍽️ Ernährung" to = "/nutrition" toLabel = "Import" / >
< div className = "card" style = { { color : 'var(--danger)' , marginTop : 8 } } > { vizErr } < / div >
2026-04-19 17:13:59 +02:00
< / div >
)
}
2026-03-16 13:35:11 +01:00
2026-04-19 17:20:24 +02:00
if ( ! viz ? . has _nutrition _entries ) {
return (
< EmptySection text = "Noch keine Ernährungsdaten." to = "/nutrition" toLabel = "FDDB importieren" / >
)
}
const summary = viz . summary || { }
const n = Math . max ( 0 , Number ( summary . data _points ) || 0 )
const avgKcal = Math . round ( Number ( summary . kcal _avg ) || 0 )
const ptLow = Math . round ( Number ( viz . protein _reference _line _g ) || 0 )
const chartDays = viz . nutrition _charts _days || ( period === 9999 ? 90 : period )
const kpiTiles = ( viz . kpi _tiles || [ ] ) . map ( t => ( {
... t ,
sublabel : typeof t . sublabel === 'string' && t . sublabel . length > 36 ? ` ${ t . sublabel . slice ( 0 , 34 ) } … ` : t . sublabel ,
} ) )
const pieData = viz . donut _avg _pct || [ ]
const cdMacro = ( viz . daily _macros || [ ] ) . map ( d => ( {
2026-03-16 13:35:11 +01:00
date : fmtDate ( d . date ) ,
2026-04-19 17:20:24 +02:00
Protein : d . Protein ,
KH : d . KH ,
Fett : d . Fett ,
kcal : d . kcal ,
2026-03-16 13:35:11 +01:00
} ) )
2026-04-19 17:20:24 +02:00
const weeklyMacro = viz . weekly _macro _chart
const wmLoading = false
const wmError = null
2026-03-16 13:35:11 +01:00
2026-04-19 17:20:24 +02:00
if ( ! cdMacro . length || n === 0 ) {
return (
< div >
< SectionHeader title = "🍽️ Ernährung" to = "/nutrition" toLabel = "Import" lastUpdated = { viz . last _updated } / >
< PeriodSelector value = { period } onChange = { setPeriod } / >
< EmptySection text = "Keine Einträge im gewählten Zeitraum." / >
< / div >
)
2026-04-19 17:13:59 +02:00
}
2026-03-16 13:35:11 +01:00
return (
< div >
2026-04-19 17:20:24 +02:00
< SectionHeader title = "🍽️ Ernährung" to = "/nutrition" toLabel = "Import" lastUpdated = { viz . last _updated } / >
2026-03-16 13:35:11 +01:00
< PeriodSelector value = { period } onChange = { setPeriod } / >
2026-04-19 17:13:59 +02:00
< p style = { { fontSize : 11 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 10 } } >
2026-04-19 17:20:24 +02:00
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI - Platzhalter ( Ernährungs - Data - Layer ) . { ' ' }
< strong > Kalorien vs . Gewicht < / strong > und TDEE - Referenz entsprechen Mifflin – St Jeor × PAL 1 , 55 bzw . kg - Fallback ( 32 , 5 kcal / kg ) .
2026-04-19 17:13:59 +02:00
< / p >
2026-03-16 13:35:11 +01:00
2026-04-19 17:13:59 +02:00
< NutritionGoalsStrip grouped = { groupedGoals } / >
< KpiTilesOverview tiles = { kpiTiles } / >
2026-04-19 17:20:24 +02:00
< KcalVsWeightChart
vizKcalWeight = { viz . kcal _vs _weight }
corrData = { [ ] }
profile = { profile }
cutoffDate = ""
allTime = { period === 9999 }
/ >
2026-04-19 17:13:59 +02:00
< div className = "card" style = { { marginBottom : 12 } } >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 6 } } >
Makroverteilung täglich ( g ) · Fokus Protein
2026-03-16 13:35:11 +01:00
< / div >
2026-04-19 17:13:59 +02:00
< div style = { { fontSize : 10 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 8 } } >
2026-04-19 17:20:24 +02:00
Gestapelte Balken in Gramm ; gestrichelte Linie = Protein - Minimum ( { ptLow || '—' } g ) nach 1 , 6 g / kg ( Referenzgewicht ) .
2026-04-19 17:13:59 +02:00
< / div >
< ResponsiveContainer width = "100%" height = { 200 } >
< BarChart data = { cdMacro } margin = { { top : 6 , right : 8 , bottom : 0 , left : - 18 } } >
< CartesianGrid stroke = "var(--border)" strokeDasharray = "3 3" vertical = { false } / >
< 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 } / >
2026-04-19 17:20:24 +02:00
{ ptLow > 0 && (
2026-04-19 17:28:41 +02:00
< ReferenceLine y = { ptLow } stroke = { MACRO _CHART . protein } strokeDasharray = "5 4" strokeWidth = { 2 } label = { { value : ` ${ ptLow } g P ` , fontSize : 9 , fill : MACRO _CHART . protein , position : 'insideTopRight' } } / >
2026-04-19 17:20:24 +02:00
) }
2026-04-19 17:13:59 +02:00
< Tooltip contentStyle = { { background : 'var(--surface)' , border : '1px solid var(--border)' , borderRadius : 8 , fontSize : 11 } } formatter = { ( v , name ) => [ ` ${ v } g ` , name ] } / >
2026-04-19 17:28:41 +02:00
< Bar dataKey = "Protein" stackId = "a" fill = { MACRO _CHART . protein } name = "Protein" / >
< Bar dataKey = "Fett" stackId = "a" fill = { MACRO _CHART . fat } name = "Fett" / >
< Bar dataKey = "KH" stackId = "a" fill = { MACRO _CHART . carbs } name = "KH" radius = { [ 5 , 5 , 0 , 0 ] } / >
2026-03-16 13:35:11 +01:00
< / BarChart >
< / ResponsiveContainer >
2026-04-19 17:13:59 +02:00
< div style = { { display : 'flex' , gap : 12 , justifyContent : 'center' , marginTop : 8 , fontSize : 10 , color : 'var(--text3)' , flexWrap : 'wrap' } } >
2026-04-19 17:28:41 +02:00
< span > < span style = { { display : 'inline-block' , width : 10 , height : 10 , background : MACRO _CHART . protein , borderRadius : 2 , verticalAlign : 'middle' , marginRight : 4 } } / > Protein ( unten ) < / span >
< span > < span style = { { display : 'inline-block' , width : 10 , height : 10 , background : MACRO _CHART . fat , borderRadius : 2 , verticalAlign : 'middle' , marginRight : 4 } } / > Fett ( Mitte ) < / span >
< span > < span style = { { display : 'inline-block' , width : 10 , height : 10 , background : MACRO _CHART . carbs , borderRadius : 2 , verticalAlign : 'middle' , marginRight : 4 } } / > KH ( oben ) < / span >
2026-03-16 13:35:11 +01:00
< / div >
< / div >
2026-04-19 17:13:59 +02:00
< div className = "nutrition-macro-pair" >
< div className = "card nutrition-macro-pair__donut" >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 } } >
Ø Makro - Quote ( { n } Tage )
< / div >
2026-04-19 17:28:41 +02:00
{ pieData . length > 0 ? (
< div className = "nutrition-macro-pair__donut-inner" >
< div className = "nutrition-macro-pair__donut-chart" >
< ResponsiveContainer width = "100%" height = { NUTRITION _MACRO _CHART _BLOCK _PX } >
< PieChart >
< Pie
data = { pieData }
cx = "50%"
cy = "50%"
innerRadius = "38%"
outerRadius = "58%"
dataKey = "value"
startAngle = { 90 }
endAngle = { - 270 }
paddingAngle = { 1 }
>
{ pieData . map ( ( e , i ) => (
< Cell key = { i } fill = { macroFillByName ( e . name ) } / >
) ) }
< / Pie >
< Tooltip formatter = { ( v , name ) => [ ` ${ v } % ` , name ] } / >
< / PieChart >
< / ResponsiveContainer >
< / div >
< div className = "nutrition-macro-pair__legend" >
{ pieData . map ( p => {
const fill = macroFillByName ( p . name )
return (
< div key = { p . name } style = { { display : 'flex' , alignItems : 'center' , gap : 8 , marginBottom : 4 } } >
< div style = { { width : 10 , height : 10 , borderRadius : 2 , background : fill , flexShrink : 0 } } / >
2026-04-19 17:20:24 +02:00
< div style = { { flex : 1 , fontSize : 13 } } > { p . name } < / div >
2026-04-19 17:28:41 +02:00
< div style = { { fontSize : 13 , fontWeight : 600 , color : fill } } > { p . value } % < / div >
2026-04-19 17:20:24 +02:00
< div style = { { fontSize : 11 , color : 'var(--text3)' } } >
{ p . grams != null ? ` ${ p . grams } g ` : '—' }
< / div >
< / div >
2026-04-19 17:28:41 +02:00
)
} ) }
< div style = { { marginTop : 8 , fontSize : 11 , color : 'var(--text3)' , borderTop : '1px solid var(--border)' , paddingTop : 8 } } >
Ø { avgKcal } kcal / Tag · Anteil der Makro - Kalorien am Tagesumsatz
2026-04-19 17:13:59 +02:00
< / div >
2026-04-19 17:28:41 +02:00
< / div >
< / div >
) : (
< div style = { { fontSize : 12 , color : 'var(--text3)' } } > Keine Makro - Mittelwerte im Zeitraum . < / div >
) }
2026-03-16 13:35:11 +01:00
< / div >
2026-04-19 17:13:59 +02:00
< div className = "card nutrition-macro-pair__weekly" >
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 4 } } >
Wöchentliche Makro - Verteilung ( Backend )
< / div >
< WeeklyMacroDistributionPanel macroWeeklyData = { weeklyMacro } loading = { wmLoading } error = { wmError } / >
2026-03-16 13:35:11 +01:00
< / div >
< / div >
2026-03-29 07:02:54 +02:00
2026-04-19 17:13:59 +02:00
< div style = { { fontSize : 12 , fontWeight : 600 , color : 'var(--text3)' , marginBottom : 8 , marginTop : 4 } } >
Zeitverläufe ( Energie & Protein )
2026-03-29 07:02:54 +02:00
< / div >
2026-04-19 17:43:29 +02:00
< NutritionCharts days = { chartDays } showWeeklyMacroDistribution = { false } hideEnergyAvailabilityCard / >
2026-03-29 07:02:54 +02:00
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 (
2026-04-19 21:27:12 +02:00
< EmptySection text = "Noch keine Aktivitätsdaten." to = "/activity" toLabel = "Fitness erfassen" / >
2026-03-16 13:35:11 +01:00
)
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 >
2026-04-19 21:27:12 +02:00
< SectionHeader title = "🏋️ Fitness" to = "/activity" toLabel = "Alle Einträge" lastUpdated = { activities [ 0 ] ? . date } / >
2026-03-16 13:35:11 +01:00
< 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
2026-04-19 17:13:59 +02:00
// Protein vs Lean Mass (only days with both)
2026-03-16 13:35:11 +01:00
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" / >
2026-04-19 17:13:59 +02:00
< p style = { { fontSize : 11 , color : 'var(--text3)' , lineHeight : 1.45 , marginBottom : 12 } } >
Das Diagramm < strong > Kalorien ( Ø 7 T ) vs . Gewicht < / strong > liegt unter < strong > Verlauf → Ernährung < / strong > ( gleiche Datenbasis ) .
< / p >
2026-03-16 13:35:11 +01:00
2026-04-19 17:13:59 +02:00
{ /* Chart: Calorie balance */ }
2026-03-16 13:35:11 +01:00
< 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' } ,
2026-04-19 21:27:12 +02:00
{ id : 'activity' , label : '🏋️ Fitness' } ,
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-19 17:20:24 +02:00
{ tab === 'nutrition' && < NutritionSection profile = { profile } { ...sp } / > }
2026-04-05 08:46:37 +02:00
{ 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 >
)
}