feat: add compact evaluation tiles and body goals section
- 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:
parent
42ae796448
commit
157afd10b9
|
|
@ -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; }
|
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
|
||||||
|
|
||||||
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
|
/* 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 {
|
.history-page__title {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import { useProfile } from '../context/ProfileContext'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar,
|
LineChart, Line, BarChart, Bar,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
ReferenceLine, PieChart, Pie, Cell
|
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
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 { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
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 }) {
|
function InsightBox({ insights, slugs, onRequest, loading }) {
|
||||||
const [expanded, setExpanded] = useState(null)
|
const [expanded, setExpanded] = useState(null)
|
||||||
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
||||||
|
|
@ -154,9 +241,18 @@ function PeriodSelector({ value, onChange }) {
|
||||||
// ── Body Section (Weight + Composition combined) ──────────────────────────────
|
// ── Body Section (Weight + Composition combined) ──────────────────────────────
|
||||||
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||||
const [period, setPeriod] = useState(90)
|
const [period, setPeriod] = useState(90)
|
||||||
|
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||||
const sex = profile?.sex||'m'
|
const sex = profile?.sex||'m'
|
||||||
const height = profile?.height||178
|
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 cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
||||||
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
|
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
|
||||||
.filter(d=>period===9999||d.date>=cutoff)
|
.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}
|
return {label:`${days}T`,diff,count:per.length}
|
||||||
}).filter(Boolean)
|
}).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=>({
|
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 latestCal = filtCal[0]
|
||||||
const prevCal = filtCal[1]
|
const prevCal = filtCal[1]
|
||||||
|
|
@ -207,11 +303,37 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
||||||
const latestW2 = filtW[filtW.length-1]
|
const latestW2 = filtW[filtW.length-1]
|
||||||
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
|
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=>({
|
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
|
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 ──
|
// ── Indicators ──
|
||||||
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
|
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
|
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
|
weight:latestW2?.weight
|
||||||
}
|
}
|
||||||
const rules = getInterpretation(combined, profile, prevCal||null)
|
const rules = getInterpretation(combined, profile, prevCal||null)
|
||||||
|
const derivedFFMI = calcDerived(combined, height)?.ffmi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
|
<SectionHeader title="⚖️ Körper" lastUpdated={weights[0]?.date||calipers[0]?.date}/>
|
||||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
<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 */}
|
{/* Summary stats */}
|
||||||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
<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'}}>
|
{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:16,fontWeight:700,color:'#1D9E75'}}>{latestCal.lean_mass} kg</div>
|
||||||
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
|
<div style={{fontSize:9,color:'var(--text3)'}}>Mager</div>
|
||||||
</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',
|
{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)'}`}}>
|
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>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* KF + Magermasse chart */}
|
{/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */}
|
||||||
{bfCd.length>=2 && (
|
{bfCd.length>=2 && (
|
||||||
<div className="card" style={{marginBottom:12}}>
|
<div className="card" style={{marginBottom:12}}>
|
||||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
<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/>
|
<NavToCaliper/>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={170}>
|
<ResponsiveContainer width="100%" height={170}>
|
||||||
<LineChart data={bfCd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
<LineChart data={bfCd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
<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 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']}/>
|
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
<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']}/>
|
formatter={(v) => [`${v}%`, 'KF%']}/>
|
||||||
{profile?.goal_bf_pct && <ReferenceLine yAxisId="bf" y={profile.goal_bf_pct}
|
{profile?.goal_bf_pct && <ReferenceLine y={profile.goal_bf_pct}
|
||||||
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
|
stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5}
|
||||||
label={{value:`Ziel ${profile.goal_bf_pct}%`,fontSize:9,fill:'#D85A30',position:'right'}}/>}
|
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 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"/>
|
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
<div style={{fontSize:10,color:'var(--text3)',marginTop:6,lineHeight:1.4}}>
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#D85A30',verticalAlign:'middle',marginRight:3}}/>KF%</span>
|
Magermasse ergibt sich aus Gewicht und KF% — als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben.
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Circ trend */}
|
{/* Proportion: V-Taper vs. Bauch (Brust−Taille vs. Bauchumfang) */}
|
||||||
{cirCd.length>=2 && (
|
{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 className="card" style={{marginBottom:12}}>
|
||||||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
<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/>
|
<NavToCircum/>
|
||||||
</div>
|
</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}>
|
<ResponsiveContainer width="100%" height={150}>
|
||||||
<LineChart data={cirCd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
<LineChart data={cirCd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||||
|
|
@ -368,36 +578,7 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* WHR / WHtR detail */}
|
<EvaluationTileGrid items={rules} />
|
||||||
{(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 <{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 <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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline','koerper','gesundheit','ziele'])}
|
||||||
onRequest={onRequest} loading={loadingSlug}/>
|
onRequest={onRequest} loading={loadingSlug}/>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user