feat: add compact evaluation tiles and body goals section
All checks were successful
Deploy Development / deploy (push) Successful in 1m2s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced a new `EvaluationTileGrid` component for displaying compact evaluation tiles with interactive features.
- Added a `BodyGoalsStrip` component to showcase active body-related goals with progress indicators.
- Enhanced CSS styles for the new components to ensure responsive design and improved user experience.
- Updated the `BodySection` to integrate the new components and manage grouped goals effectively.
This commit is contained in:
Lars 2026-04-19 15:55:46 +02:00
parent 42ae796448
commit 157afd10b9
2 changed files with 255 additions and 50 deletions

View File

@ -199,6 +199,30 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
/* Körper-Verlauf: kompakte Bewertungs-Kacheln */
.body-eval-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
.body-eval-tile {
display: block;
width: 100%;
background: var(--surface2);
border-radius: 10px;
padding: 8px 10px;
cursor: pointer;
border: 1px solid var(--border);
font: inherit;
color: inherit;
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.body-eval-tile:hover {
border-color: var(--border2);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.history-page__title {
margin-bottom: 12px;
}

View File

@ -4,12 +4,12 @@ import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell
ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
import { getBfCategory } from '../utils/calc'
import { getBfCategory, calcDerived } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
@ -85,6 +85,93 @@ function RuleCard({ item }) {
)
}
/** Kompakte Bewertungs-Kacheln (z. B. Körper-Verlauf) */
function EvaluationTileGrid({ items }) {
const [open, setOpen] = useState(null)
if (!items?.length) return null
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>BEWERTUNG</div>
<div className="body-eval-grid">
{items.map((item, i) => {
const color = getStatusColor(item.status)
const expanded = open === i
return (
<button
key={i}
type="button"
className="body-eval-tile"
style={{ borderTop: `3px solid ${color}` }}
onClick={() => setOpen(expanded ? null : i)}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, textAlign: 'left', width: '100%' }}>
<span style={{ fontSize: 16, lineHeight: 1 }}>{item.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 9, fontWeight: 600, color, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>{item.category}</div>
<div style={{ fontSize: 12, fontWeight: 600, lineHeight: 1.3, color: 'var(--text1)' }}>{item.title}</div>
{expanded && (
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
)}
</div>
{item.value && (
<span style={{ fontSize: 13, fontWeight: 700, color, flexShrink: 0 }}>{item.value}</span>
)}
</div>
</button>
)
})}
</div>
</div>
)
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
@ -154,9 +241,18 @@ function PeriodSelector({ value, onChange }) {
// Body Section (Weight + Composition combined)
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(90)
const [groupedGoals, setGroupedGoals] = useState(null)
const sex = profile?.sex||'m'
const height = profile?.height||178
useEffect(() => {
let cancelled = false
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
.filter(d=>period===9999||d.date>=cutoff)
@ -197,9 +293,9 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
return {label:`${days}T`,diff,count:per.length}
}).filter(Boolean)
// Caliper chart
// Caliper: nur KF% (Magermasse ist daraus abgeleitet eigene zweite Achse entfällt)
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
date:fmtDate(c.date),bf:c.body_fat_pct,lean:c.lean_mass,fat:c.fat_mass
date:fmtDate(c.date), bf:c.body_fat_pct,
}))
const latestCal = filtCal[0]
const prevCal = filtCal[1]
@ -207,11 +303,37 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
const latestW2 = filtW[filtW.length-1]
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
// Circ chart
// Umfänge: chronologisch für Trends & Proportionen
const circChron = [...filtCir].sort((a,b)=>a.date.localeCompare(b.date))
const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({
date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly
}))
const propBase = circChron
.filter(r => r.c_chest && r.c_waist)
.map(r => ({
date: fmtDate(r.date),
vTaper: Math.round((r.c_chest - r.c_waist) * 10) / 10,
belly: r.c_belly != null ? Math.round(r.c_belly * 10) / 10 : null,
}))
const propChartData = propBase.length >= 2 ? rollingAvg(propBase, 'vTaper', 3) : []
const showBellyOnProp = propChartData.some(d => d.belly != null)
const fbFirst = { chest: null, waist: null, belly: null }
for (const r of circChron) {
if (fbFirst.chest == null && r.c_chest) fbFirst.chest = r.c_chest
if (fbFirst.waist == null && r.c_waist) fbFirst.waist = r.c_waist
if (fbFirst.belly == null && r.c_belly) fbFirst.belly = r.c_belly
}
const idxSeries = circChron.map(r => ({
date: fmtDate(r.date),
chestIdx: r.c_chest && fbFirst.chest ? Math.round((r.c_chest / fbFirst.chest) * 1000) / 10 : null,
waistIdx: r.c_waist && fbFirst.waist ? Math.round((r.c_waist / fbFirst.waist) * 1000) / 10 : null,
bellyIdx: r.c_belly && fbFirst.belly ? Math.round((r.c_belly / fbFirst.belly) * 1000) / 10 : null,
}))
const idxCount = idxSeries.filter(row => row.chestIdx != null || row.waistIdx != null || row.bellyIdx != null).length
const idxOk = idxCount >= 2 && (fbFirst.chest || fbFirst.waist || fbFirst.belly)
// Indicators
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
@ -223,12 +345,20 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
weight:latestW2?.weight
}
const rules = getInterpretation(combined, profile, prevCal||null)
const derivedFFMI = calcDerived(combined, height)?.ffmi
return (
<div>
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Hinweis: Diese Seite bündelt <strong>Körpermaße und -zusammensetzung</strong>. Trainingsbedingte Fitness (Belastung, Leistung, Ausdauer) findest du unter{' '}
<strong>Verlauf Aktivität</strong> dort werden sportliche Trends ausgewertet, hier geht es um Silhouette, Zusammensetzung und Gesundheitsindikatoren.
</p>
{/* Summary stats */}
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{latestW2 && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
@ -243,6 +373,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
<div style={{fontSize:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
</div>}
{derivedFFMI != null && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:16,fontWeight:700,color:'#378ADD'}}>{derivedFFMI}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>FFMI</div>
</div>}
{whr && <div style={{flex:1,minWidth:70,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:16,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
@ -316,43 +450,119 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
</div>
)}
{/* KF + Magermasse chart */}
{/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */}
{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)'}}>KF% + Magermasse</div>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Körperfett (Caliper)</div>
<NavToCaliper/>
</div>
<ResponsiveContainer width="100%" height={170}>
<LineChart data={bfCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="bf" 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']}/>
<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}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/>
{profile?.goal_bf_pct && <ReferenceLine yAxisId="bf" y={profile.goal_bf_pct}
formatter={(v) => [`${v}%`, 'KF%']}/>
{profile?.goal_bf_pct && <ReferenceLine y={profile.goal_bf_pct}
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
<Line yAxisId="bf" type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#1D9E75" strokeWidth={2} dot={{r:3,fill:'#1D9E75'}} name="lean"/>
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{r:4,fill:'#D85A30'}} name="bf"/>
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3}}/>KF%</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Mager kg</span>
{profile?.goal_bf_pct && <span><span style={{display:'inline-block',width:14,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #D85A30'}}/>Ziel KF</span>}
<div style={{fontSize:10,color:'var(--text3)',marginTop:6,lineHeight:1.4}}>
Magermasse ergibt sich aus Gewicht und KF% als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben.
</div>
</div>
)}
{/* Circ trend */}
{cirCd.length>=2 && (
{/* Proportion: V-Taper vs. Bauch (BrustTaille vs. Bauchumfang) */}
{propChartData.length >= 2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start',marginBottom:8,gap:8}}>
<div>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Silhouette & Proportion</div>
<div style={{fontSize:10,color:'var(--text3)',lineHeight:1.45,marginTop:4}}>
<strong>V-Taper (Brust Taille)</strong> in cm: größer bedeutet stärkere Schulter-/Brustentwicklung relativ zur Taille.
{showBellyOnProp && (
<><strong> Bauch</strong> (rechte Achse): steigender Trend hier deutet eher auf Zunahme zentralen Umfangs hin unabhängig von sportlicher Brustentwicklung.</>
)}
</div>
</div>
<NavToCircum/>
</div>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart data={propChartData} margin={{top:4,right:showBellyOnProp?4:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis yAxisId="taper" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
{showBellyOnProp && (
<YAxis yAxisId="belly" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
)}
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v, name) => {
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust Taille']
if (name === 'belly') return [`${v} cm`, 'Bauch']
return [v, name]
}}/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{r:3}} name="vTaper"/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg"/>
{showBellyOnProp && (
<Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{r:3}} connectNulls name="belly"/>
)}
</ComposedChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)',flexWrap:'wrap'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Brust Taille</span>
<span><span style={{display:'inline-flex',alignItems:'center',gap:3}}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4"/></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{display:'inline-block',width:12,height:2,background:'#D4537E',verticalAlign:'middle',marginRight:3}}/>Bauch (cm)</span>}
</div>
</div>
)}
{/* Relative Umfangsänderung (Index erste Messung im Zeitraum = 100) */}
{idxOk && (
<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 Verlauf</div>
<div>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Relative Entwicklung der Umfänge</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4,lineHeight:1.4}}>
Index 100 = erste erfasste Messung im Zeitraum. So sind Trend und Richtung besser vergleichbar als absolute cm-Werte nebeneinander.
</div>
</div>
<NavToCircum/>
</div>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={idxSeries} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${v} Index`, n==='chestIdx'?'Brust':n==='waistIdx'?'Taille':'Bauch']}/>
{idxSeries.some(d=>d.chestIdx!=null) && <Line type="monotone" dataKey="chestIdx" stroke="#1D9E75" strokeWidth={2} dot={{r:2}} connectNulls name="chestIdx"/>}
{idxSeries.some(d=>d.waistIdx!=null) && <Line type="monotone" dataKey="waistIdx" stroke="#EF9F27" strokeWidth={2} dot={{r:2}} connectNulls name="waistIdx"/>}
{idxSeries.some(d=>d.bellyIdx!=null) && <Line type="monotone" dataKey="bellyIdx" stroke="#D4537E" strokeWidth={2} dot={{r:2}} connectNulls name="bellyIdx"/>}
</LineChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)',flexWrap:'wrap'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3}}/>Brust</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Taille</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#D4537E',verticalAlign:'middle',marginRight:3}}/>Bauch</span>
</div>
</div>
)}
{/* Fallback: klassischer Taille/Hüfte/Bauch-Verlauf wenn keine Brust-Taille-Kombi */}
{propChartData.length < 2 && cirCd.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginBottom:8,lineHeight:1.4}}>
Sobald Brust- und Taillenumfang gemeinsam erfasst sind, erscheint oben die Proportionen-Ansicht (V-Taper).
</div>
<ResponsiveContainer width="100%" height={150}>
<LineChart data={cirCd} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
@ -368,36 +578,7 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
</div>
)}
{/* WHR / WHtR detail */}
{(whr||whtr) && (
<div style={{display:'flex',gap:8,marginBottom:12}}>
{whr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>{whr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Hüfte</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;{sex==='m'?'0,90':'0,85'}</div>
<div style={{fontSize:10,color:whr<(sex==='m'?0.90:0.85)?'var(--accent)':'var(--warn)'}}>
{whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}</div>
</div>}
{whtr && <div style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'10px',textAlign:'center',
borderTop:`3px solid ${whtr<0.5?'var(--accent)':'var(--warn)'}`}}>
<div style={{fontSize:20,fontWeight:700,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>{whtr}</div>
<div style={{fontSize:11,fontWeight:600,color:'var(--text3)'}}>WHtR</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Taille ÷ Körpergröße</div>
<div style={{fontSize:10,color:'var(--text3)'}}>Ziel &lt;0,50</div>
<div style={{fontSize:10,color:whtr<0.5?'var(--accent)':'var(--warn)'}}>
{whtr<0.5?' Optimal':' Erhöht'}</div>
</div>}
</div>
)}
{rules.length>0 && (
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{rules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
)}
<EvaluationTileGrid items={rules} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
onRequest={onRequest} loading={loadingSlug}/>