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 09:20:28 +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-03-16 13:35:11 +01:00
|
|
|
|
import Markdown from '../utils/Markdown'
|
2026-03-21 16:56:35 +01:00
|
|
|
|
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
2026-03-29 07:02:54 +02:00
|
|
|
|
import NutritionCharts from '../components/NutritionCharts'
|
|
|
|
|
|
import RecoveryCharts from '../components/RecoveryCharts'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import 'dayjs/locale/de'
|
|
|
|
|
|
dayjs.locale('de')
|
|
|
|
|
|
|
|
|
|
|
|
function rollingAvg(arr, key, window=7) {
|
|
|
|
|
|
return arr.map((d,i) => {
|
|
|
|
|
|
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
|
|
|
|
|
|
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
const fmtDate = d => dayjs(d).format('DD.MM')
|
|
|
|
|
|
|
|
|
|
|
|
function NavToCaliper() {
|
|
|
|
|
|
const nav = useNavigate()
|
|
|
|
|
|
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
|
|
|
|
|
|
onClick={()=>nav('/caliper')}>Caliper-Daten <ChevronRight size={10}/></button>
|
|
|
|
|
|
}
|
|
|
|
|
|
function NavToCircum() {
|
|
|
|
|
|
const nav = useNavigate()
|
|
|
|
|
|
return <button className="btn btn-secondary" style={{fontSize:10,padding:'2px 8px'}}
|
|
|
|
|
|
onClick={()=>nav('/circum')}>Umfang-Daten <ChevronRight size={10}/></button>
|
|
|
|
|
|
}
|
|
|
|
|
|
function EmptySection({ text, to, toLabel }) {
|
|
|
|
|
|
const nav = useNavigate()
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{padding:32,textAlign:'center',color:'var(--text3)',fontSize:13}}>
|
|
|
|
|
|
<div style={{marginBottom:12}}>{text}</div>
|
|
|
|
|
|
{to && <button className="btn btn-primary" onClick={()=>nav(to)}>{toLabel||'Daten erfassen'}</button>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function SectionHeader({ title, to, toLabel, lastUpdated }) {
|
|
|
|
|
|
const nav = useNavigate()
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:14}}>
|
|
|
|
|
|
<h2 style={{fontSize:17,fontWeight:700,margin:0}}>{title}</h2>
|
|
|
|
|
|
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
|
|
|
|
{lastUpdated && <span style={{fontSize:11,color:'var(--text3)'}}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
|
|
|
|
|
|
{to && (
|
|
|
|
|
|
<button className="btn btn-secondary" style={{fontSize:12,padding:'5px 10px'}} onClick={()=>nav(to)}>
|
|
|
|
|
|
{toLabel||'Daten'} <ChevronRight size={12}/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function RuleCard({ item }) {
|
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
|
const color = getStatusColor(item.status)
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{border:`1px solid ${color}33`,borderRadius:8,marginBottom:6,overflow:'hidden'}}>
|
|
|
|
|
|
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
|
|
|
|
|
|
background:getStatusBg(item.status)+'88',cursor:'pointer'}} onClick={()=>setOpen(o=>!o)}>
|
|
|
|
|
|
<span style={{fontSize:15}}>{item.icon}</span>
|
|
|
|
|
|
<div style={{flex:1}}>
|
|
|
|
|
|
<div style={{fontSize:11,fontWeight:600,color,textTransform:'uppercase',letterSpacing:'0.04em'}}>{item.category}</div>
|
|
|
|
|
|
<div style={{fontSize:13,fontWeight:500}}>{item.title}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{item.value && <span style={{fontSize:14,fontWeight:700,color}}>{item.value}</span>}
|
|
|
|
|
|
{open ? <ChevronUp size={14} color="var(--text3)"/> : <ChevronDown size={14} color="var(--text3)"/>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{open && <div style={{padding:'8px 12px',fontSize:12,color:'var(--text2)',lineHeight:1.6,
|
|
|
|
|
|
borderTop:`1px solid ${color}22`}}>{item.detail}</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 15:55:46 +02:00
|
|
|
|
/** 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 && (
|
2026-04-19 16:06:07 +02:00
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
|
|
|
|
|
|
{item.related_placeholder_keys?.length > 0 && (
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 6, lineHeight: 1.35 }}>
|
|
|
|
|
|
Layer 2a (Registry): {item.related_placeholder_keys.join(', ')}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2026-04-19 15:55:46 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
function InsightBox({ insights, slugs, onRequest, loading }) {
|
|
|
|
|
|
const [expanded, setExpanded] = useState(null)
|
|
|
|
|
|
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
|
|
|
|
|
const LABELS = {gesamt:'Gesamt',koerper:'Komposition',ernaehrung:'Ernährung',
|
|
|
|
|
|
aktivitaet:'Aktivität',gesundheit:'Gesundheit',ziele:'Ziele',
|
|
|
|
|
|
pipeline:'🔬 Mehrstufige Analyse',
|
|
|
|
|
|
pipeline_body:'Pipeline Körper',pipeline_nutrition:'Pipeline Ernährung',
|
|
|
|
|
|
pipeline_activity:'Pipeline Aktivität',pipeline_synthesis:'Pipeline Synthese',
|
|
|
|
|
|
pipeline_goals:'Pipeline Ziele'}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{marginTop:14}}>
|
|
|
|
|
|
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)'}}>🤖 KI-AUSWERTUNGEN</div>
|
|
|
|
|
|
<div style={{display:'flex',gap:6,flexWrap:'wrap'}}>
|
|
|
|
|
|
{slugs.map(slug=>(
|
|
|
|
|
|
<button key={slug} className="btn btn-secondary" style={{fontSize:11,padding:'4px 8px'}}
|
|
|
|
|
|
onClick={()=>onRequest(slug)} disabled={loading===slug}>
|
|
|
|
|
|
{loading===slug
|
|
|
|
|
|
? <div className="spinner" style={{width:11,height:11}}/>
|
|
|
|
|
|
: <><Brain size={11}/> {LABELS[slug]||slug}</>}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{relevant.length===0 && (
|
|
|
|
|
|
<div style={{padding:'10px 12px',background:'var(--surface2)',borderRadius:8,
|
|
|
|
|
|
fontSize:12,color:'var(--text3)'}}>
|
|
|
|
|
|
Noch keine Auswertung. Klicke oben um eine zu erstellen.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{relevant.map(ins=>(
|
|
|
|
|
|
<div key={ins.id} style={{marginBottom:8,border:'1px solid var(--accent)33',borderRadius:10,overflow:'hidden'}}>
|
|
|
|
|
|
<div style={{display:'flex',alignItems:'center',gap:8,padding:'8px 12px',
|
|
|
|
|
|
background:'var(--accent-light)66',cursor:'pointer'}}
|
|
|
|
|
|
onClick={()=>setExpanded(expanded===ins.id?null:ins.id)}>
|
|
|
|
|
|
<div style={{flex:1,fontSize:11,color:'var(--accent)',fontWeight:600}}>
|
|
|
|
|
|
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{expanded===ins.id?<ChevronUp size={14}/>:<ChevronDown size={14}/>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{expanded===ins.id && <div style={{padding:'12px 14px'}}><Markdown text={ins.content}/></div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Period selector ───────────────────────────────────────────────────────────
|
|
|
|
|
|
function PeriodSelector({ value, onChange }) {
|
|
|
|
|
|
const opts = [{v:30,l:'30 Tage'},{v:90,l:'90 Tage'},{v:180,l:'6 Monate'},{v:365,l:'1 Jahr'},{v:9999,l:'Alles'}]
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{display:'flex',gap:4,marginBottom:12}}>
|
|
|
|
|
|
{opts.map(o=>(
|
|
|
|
|
|
<button key={o.v} onClick={()=>onChange(o.v)}
|
|
|
|
|
|
style={{padding:'4px 10px',borderRadius:12,fontSize:11,fontWeight:500,border:'1.5px solid',
|
|
|
|
|
|
cursor:'pointer',fontFamily:'var(--font)',
|
|
|
|
|
|
background:value===o.v?'var(--accent)':'transparent',
|
|
|
|
|
|
borderColor:value===o.v?'var(--accent)':'var(--border2)',
|
|
|
|
|
|
color:value===o.v?'white':'var(--text2)'}}>
|
|
|
|
|
|
{o.l}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ──
|
|
|
|
|
|
function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [period, setPeriod] = useState(90)
|
2026-04-19 15:55:46 +02:00
|
|
|
|
const [groupedGoals, setGroupedGoals] = useState(null)
|
2026-04-19 16:06:07 +02:00
|
|
|
|
const [viz, setViz] = useState(null)
|
|
|
|
|
|
const [vizLoading, setVizLoading] = useState(true)
|
|
|
|
|
|
const [vizError, setVizError] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
const sex = profile?.sex || 'm'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-19 15:55:46 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
api.listGoalsGrouped()
|
|
|
|
|
|
.then(g => { if (!cancelled) setGroupedGoals(g) })
|
|
|
|
|
|
.catch(() => { if (!cancelled) setGroupedGoals({}) })
|
|
|
|
|
|
return () => { cancelled = true }
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
setVizLoading(true)
|
|
|
|
|
|
setVizError(null)
|
|
|
|
|
|
api.getBodyHistoryViz(period)
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setViz(data)
|
|
|
|
|
|
setVizLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(e => {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setVizError(e.message || 'Laden fehlgeschlagen')
|
|
|
|
|
|
setVizLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => { cancelled = true }
|
|
|
|
|
|
}, [period])
|
|
|
|
|
|
|
|
|
|
|
|
const w = viz?.weight
|
|
|
|
|
|
const cal = viz?.caliper
|
|
|
|
|
|
const circ = viz?.circumference
|
|
|
|
|
|
const summary = viz?.summary || {}
|
|
|
|
|
|
|
|
|
|
|
|
const wCd = (w?.series || []).map(row => ({
|
|
|
|
|
|
date: fmtDate(row.date),
|
|
|
|
|
|
weight: row.weight,
|
|
|
|
|
|
avg7: row.avg7,
|
|
|
|
|
|
avg14: row.avg14,
|
|
|
|
|
|
}))
|
|
|
|
|
|
const hasWeight = (w?.data_points || 0) >= 2
|
|
|
|
|
|
const avgAll = w?.overall_avg_kg
|
|
|
|
|
|
const minW = w?.min_kg
|
|
|
|
|
|
const maxW = w?.max_kg
|
|
|
|
|
|
const trendPeriods = w?.trend_periods || []
|
|
|
|
|
|
|
|
|
|
|
|
const bfCd = (cal?.series || []).map(s => ({
|
|
|
|
|
|
date: fmtDate(s.date),
|
|
|
|
|
|
bf: s.body_fat_pct,
|
|
|
|
|
|
}))
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
const propChartData = (circ?.proportion_series || []).map(p => ({
|
|
|
|
|
|
date: fmtDate(p.date),
|
|
|
|
|
|
vTaper: p.v_taper_cm,
|
|
|
|
|
|
vTaper_avg: p.v_taper_cm_avg,
|
|
|
|
|
|
belly: p.belly_cm,
|
|
|
|
|
|
}))
|
|
|
|
|
|
const showBellyOnProp = propChartData.some(d => d.belly != null && d.belly !== undefined)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
const idxSeriesRaw = circ?.index_series || []
|
|
|
|
|
|
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
|
|
|
|
|
|
const idxOk = circ?.index_usable
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
const cirCd = (circ?.fallback_multiline || []).map(r => ({
|
|
|
|
|
|
date: fmtDate(r.date),
|
|
|
|
|
|
waist: r.waist,
|
|
|
|
|
|
hip: r.hip,
|
|
|
|
|
|
belly: r.belly,
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}))
|
2026-04-19 16:06:07 +02:00
|
|
|
|
|
|
|
|
|
|
const whr = summary.whr
|
|
|
|
|
|
const whtr = summary.whtr
|
|
|
|
|
|
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: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} />
|
|
|
|
|
|
|
|
|
|
|
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
2026-04-19 16:06:07 +02:00
|
|
|
|
<strong>Layer 2b</strong>: Diagramme und Bewertung stammen aus dem Backend-Bundle — dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '}
|
|
|
|
|
|
Sportliche Fitness: <strong>Verlauf → Aktivität</strong>.
|
2026-04-19 15:55:46 +02:00
|
|
|
|
</p>
|
|
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
{viz?.meta?.layer_2a_alignment && (
|
|
|
|
|
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 10, lineHeight: 1.4 }}>
|
|
|
|
|
|
{viz.meta.layer_2a_alignment}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
|
|
|
|
|
|
{summary.weight_kg != null && (
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 700 }}>{summary.weight_kg} kg</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Aktuell</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{summary.body_fat_pct != null && (
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: bfCat?.color }}>{summary.body_fat_pct}%</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>KF {bfCat?.label || summary.bf_category_label}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{summary.lean_mass_kg != null && (
|
|
|
|
|
|
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#1D9E75' }}>{summary.lean_mass_kg} kg</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Mager</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{summary.ffmi != 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' }}>{summary.ffmi}</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>FFMI</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{whr != null && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 70,
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
padding: '8px 6px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
borderTop: `3px solid ${whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)'}`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)' }}>{whr}</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHR</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{whtr != null && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
minWidth: 70,
|
|
|
|
|
|
background: 'var(--surface2)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
padding: '8px 6px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
borderTop: `3px solid ${whtr < 0.5 ? 'var(--accent)' : 'var(--warn)'}`,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ fontSize: 16, fontWeight: 700, color: whtr < 0.5 ? 'var(--accent)' : 'var(--warn)' }}>{whtr}</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHtR</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
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 }} />Ø 7T</span>
|
|
|
|
|
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>Ø 14T</span>
|
|
|
|
|
|
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-19 16:06:07 +02:00
|
|
|
|
{trendPeriods.length > 0 && (
|
|
|
|
|
|
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
|
|
|
|
|
|
{trendPeriods.map(({ label, diff_kg: diff }) => (
|
|
|
|
|
|
<div key={label} style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center', borderTop: `3px solid ${diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--border)'}` }}>
|
|
|
|
|
|
<div style={{ fontSize: 15, fontWeight: 700, color: diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--text3)' }}>
|
|
|
|
|
|
{diff > 0 ? '+' : ''}{diff} kg
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-19 16:06:07 +02:00
|
|
|
|
<div style={{ fontSize: 10, color: 'var(--text3)' }}>{label}</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-04-19 16:06:07 +02:00
|
|
|
|
{minW != null && (
|
|
|
|
|
|
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center' }}>
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--accent)', fontWeight: 600 }}>{minW}</div>
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--warn)', fontWeight: 600 }}>{maxW}</div>
|
|
|
|
|
|
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Min/Max</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
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 15:55:46 +02:00
|
|
|
|
<EvaluationTileGrid items={rules} />
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-19 16:06:07 +02:00
|
|
|
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
2026-03-19 07:47:59 +01:00
|
|
|
|
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [period, setPeriod] = useState(30)
|
|
|
|
|
|
if (!nutrition?.length) return (
|
|
|
|
|
|
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
|
|
|
|
|
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
|
|
|
|
|
|
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
|
|
|
|
|
|
|
|
|
|
|
|
if (!filtN.length) return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
|
|
|
|
|
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
|
|
|
|
|
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const n = filtN.length
|
|
|
|
|
|
const avgKcal = Math.round(filtN.reduce((s,d)=>s+(d.kcal||0),0)/n)
|
|
|
|
|
|
const avgProtein = Math.round(filtN.reduce((s,d)=>s+(d.protein_g||0),0)/n*10)/10
|
|
|
|
|
|
const avgFat = Math.round(filtN.reduce((s,d)=>s+(d.fat_g||0),0)/n*10)/10
|
|
|
|
|
|
const avgCarbs = Math.round(filtN.reduce((s,d)=>s+(d.carbs_g||0),0)/n*10)/10
|
|
|
|
|
|
const latestW = weights?.[0]?.weight||80
|
|
|
|
|
|
const ptLow = Math.round(latestW*1.6)
|
|
|
|
|
|
const ptHigh = Math.round(latestW*2.2)
|
|
|
|
|
|
const proteinOk = avgProtein>=ptLow
|
|
|
|
|
|
|
|
|
|
|
|
// Stacked macro bar (daily)
|
|
|
|
|
|
const cdMacro = sorted.map(d=>({
|
|
|
|
|
|
date: fmtDate(d.date),
|
|
|
|
|
|
Protein: Math.round(d.protein_g||0),
|
|
|
|
|
|
KH: Math.round(d.carbs_g||0),
|
|
|
|
|
|
Fett: Math.round(d.fat_g||0),
|
|
|
|
|
|
kcal: Math.round(d.kcal||0),
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
// Pie
|
|
|
|
|
|
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
|
|
|
|
|
|
const pieData = [
|
|
|
|
|
|
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
|
|
|
|
|
|
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
|
|
|
|
|
|
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
// Weekly macro bars
|
|
|
|
|
|
const weeklyMap={}
|
|
|
|
|
|
filtN.forEach(d=>{
|
|
|
|
|
|
const wk=dayjs(d.date).format('YYYY-WW')
|
|
|
|
|
|
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })()
|
|
|
|
|
|
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
|
|
|
|
|
|
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
|
|
|
|
|
|
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
|
|
|
|
|
|
})
|
|
|
|
|
|
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
|
|
|
|
|
|
label:w.label,
|
|
|
|
|
|
Protein:Math.round(w.protein/w.n),
|
|
|
|
|
|
KH:Math.round(w.carbs/w.n),
|
|
|
|
|
|
Fett:Math.round(w.fat/w.n),
|
|
|
|
|
|
kcal:Math.round(w.kcal/w.n),
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
// Rules
|
|
|
|
|
|
const macroRules=[]
|
|
|
|
|
|
if(!proteinOk) macroRules.push({status:'bad',icon:'🥩',category:'Protein',
|
|
|
|
|
|
title:`Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
|
|
|
|
|
detail:`1,6–2,2g/kg KG. Fehlend: ~${ptLow-Math.round(avgProtein)}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
|
|
|
|
|
|
value:avgProtein+'g'})
|
|
|
|
|
|
else macroRules.push({status:'good',icon:'🥩',category:'Protein',
|
|
|
|
|
|
title:`Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
|
|
|
|
|
detail:`Ausreichend für Muskelerhalt und -aufbau.`,value:avgProtein+'g'})
|
|
|
|
|
|
const protPct=Math.round(avgProtein*4/totalMacroKcal*100)
|
|
|
|
|
|
if(protPct<20) macroRules.push({status:'warn',icon:'📊',category:'Makro-Anteil',
|
|
|
|
|
|
title:`Protein-Anteil niedrig: ${protPct}% der Kalorien`,
|
|
|
|
|
|
detail:`Empfehlung: 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
|
|
|
|
|
|
value:protPct+'%'})
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
|
|
|
|
|
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
|
|
|
|
|
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
|
|
|
|
|
|
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
|
|
|
|
|
|
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
|
|
|
|
|
|
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
|
|
|
|
|
|
padding:'8px 6px',textAlign:'center'}}>
|
|
|
|
|
|
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
|
|
|
|
|
|
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Stacked macro bars (daily) */}
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
|
|
|
|
|
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={170}>
|
|
|
|
|
|
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
|
|
|
|
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/>
|
|
|
|
|
|
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
|
|
|
|
|
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
|
|
|
|
|
|
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
|
|
|
|
|
|
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
|
|
|
|
|
|
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
|
|
|
|
|
|
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/>
|
|
|
|
|
|
</BarChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
|
|
|
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span>
|
|
|
|
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span>
|
|
|
|
|
|
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span>
|
|
|
|
|
|
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Pie + macro breakdown */}
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
|
|
|
|
|
|
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} – {sorted[sorted.length-1]?.date?.slice(0,10)})
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{display:'flex',alignItems:'center',gap:16}}>
|
|
|
|
|
|
<PieChart width={110} height={110}>
|
|
|
|
|
|
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
|
|
|
|
|
|
dataKey="value" startAngle={90} endAngle={-270}>
|
|
|
|
|
|
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
|
|
|
|
|
|
</Pie>
|
|
|
|
|
|
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
|
|
|
|
|
|
</PieChart>
|
|
|
|
|
|
<div style={{flex:1}}>
|
|
|
|
|
|
{pieData.map(p=>(
|
|
|
|
|
|
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}>
|
|
|
|
|
|
<div style={{width:10,height:10,borderRadius:2,background:p.color,flexShrink:0}}/>
|
|
|
|
|
|
<div style={{flex:1,fontSize:13}}>{p.name}</div>
|
|
|
|
|
|
<div style={{fontSize:13,fontWeight:600,color:p.color}}>{p.value}%</div>
|
|
|
|
|
|
<div style={{fontSize:11,color:'var(--text3)'}}>{Math.round(p.name==='Protein'?avgProtein:p.name==='KH'?avgCarbs:avgFat)}g</div>
|
|
|
|
|
|
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
|
|
|
|
|
|
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
|
|
|
|
|
|
</div>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
|
|
|
|
|
|
Gesamt: {avgKcal} kcal/Tag
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Weekly stacked bars */}
|
|
|
|
|
|
{weeklyData.length>=2 && (
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={150}>
|
|
|
|
|
|
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
|
|
|
|
|
|
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
|
|
|
|
|
|
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
|
|
|
|
|
|
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
|
|
|
|
|
|
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
|
|
|
|
|
|
</BarChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
|
|
|
|
|
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
|
|
|
|
|
</div>
|
2026-03-29 07:02:54 +02:00
|
|
|
|
|
|
|
|
|
|
{/* New Nutrition Charts (Phase 0c) */}
|
|
|
|
|
|
<div style={{marginTop:16}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
|
|
|
|
|
|
<NutritionCharts days={period === 9999 ? 90 : period} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-19 07:47:59 +01:00
|
|
|
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Activity Section ──────────────────────────────────────────────────────────
|
2026-03-23 22:29:49 +01:00
|
|
|
|
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [period, setPeriod] = useState(30)
|
|
|
|
|
|
if (!activities?.length) return (
|
|
|
|
|
|
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Aktivität erfassen"/>
|
|
|
|
|
|
)
|
|
|
|
|
|
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
2026-03-23 22:04:29 +01:00
|
|
|
|
|
2026-03-24 08:06:20 +01:00
|
|
|
|
// Issue #31: Backend already filters by global quality level - only filter by period here
|
|
|
|
|
|
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const byDate={}
|
|
|
|
|
|
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
|
|
|
|
|
|
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
|
|
|
|
|
|
|
|
|
|
|
|
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
|
|
|
|
|
|
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
|
|
|
|
|
|
const hrData =filtA.filter(a=>a.hr_avg)
|
|
|
|
|
|
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
|
|
|
|
|
|
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
|
|
|
|
|
|
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
|
|
|
|
|
|
|
|
|
|
|
|
const daysWithAct=new Set(filtA.map(a=>a.date)).size
|
|
|
|
|
|
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
|
|
|
|
|
|
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
|
|
|
|
|
|
const actRules=[{
|
|
|
|
|
|
status:consistency>=70?'good':consistency>=40?'warn':'bad',
|
|
|
|
|
|
icon:'📅', category:'Konsistenz',
|
|
|
|
|
|
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
|
|
|
|
|
|
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 4–5 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
|
|
|
|
|
|
value:consistency+'%'
|
|
|
|
|
|
}]
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<SectionHeader title="🏋️ Aktivität" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
|
|
|
|
|
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
2026-03-23 21:59:02 +01:00
|
|
|
|
|
2026-03-24 08:06:20 +01:00
|
|
|
|
{/* Issue #31: Show active global quality filter */}
|
|
|
|
|
|
{globalQualityLevel && globalQualityLevel !== 'all' && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
marginBottom:12, padding:'8px 12px', borderRadius:8,
|
|
|
|
|
|
background:'var(--surface2)', border:'1px solid var(--border)',
|
|
|
|
|
|
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'}
|
|
|
|
|
|
{globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'}
|
|
|
|
|
|
{globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<a href="/settings" style={{
|
|
|
|
|
|
marginLeft:'auto', color:'var(--accent)', textDecoration:'none',
|
|
|
|
|
|
fontSize:11, fontWeight:500, whiteSpace:'nowrap'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
Hier ändern →
|
|
|
|
|
|
</a>
|
2026-03-23 22:04:29 +01:00
|
|
|
|
</div>
|
2026-03-24 08:06:20 +01:00
|
|
|
|
)}
|
2026-03-23 21:59:02 +01:00
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
|
|
|
|
|
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
|
|
|
|
|
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
|
|
|
|
|
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
|
|
|
|
|
|
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
|
|
|
|
|
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
|
|
|
|
|
|
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={150}>
|
|
|
|
|
|
<BarChart data={cd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
|
|
|
|
interval={Math.max(0,Math.floor(cd.length/6)-1)}/>
|
|
|
|
|
|
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
|
|
|
|
formatter={v=>[`${v} kcal`]}/>
|
|
|
|
|
|
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
|
|
|
|
|
|
</BarChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
|
|
|
|
|
|
{topTypes.map(([type,count])=>(
|
|
|
|
|
|
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
|
|
|
|
|
|
<div style={{flex:1,fontSize:13}}>{type}</div>
|
|
|
|
|
|
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
|
|
|
|
|
|
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
2026-03-21 16:56:35 +01:00
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
|
|
|
|
|
|
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
|
|
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<div style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
|
|
|
|
|
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
|
|
|
|
|
</div>
|
2026-03-19 07:47:59 +01:00
|
|
|
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Correlation Section ───────────────────────────────────────────────────────
|
2026-03-19 07:47:59 +01:00
|
|
|
|
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
|
|
|
|
|
|
if (filtered.length < 5) return (
|
|
|
|
|
|
<EmptySection text="Für Korrelationen werden Gewichts- und Ernährungsdaten benötigt (mind. 5 gemeinsame Tage)."/>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const sex = profile?.sex||'m'
|
|
|
|
|
|
const height = profile?.height||178
|
|
|
|
|
|
const latestW = filtered[filtered.length-1]?.weight||80
|
|
|
|
|
|
const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 35
|
|
|
|
|
|
const bmr = sex==='m' ? 10*latestW+6.25*height-5*age+5 : 10*latestW+6.25*height-5*age-161
|
|
|
|
|
|
const tdee = Math.round(bmr*1.4) // light activity baseline
|
|
|
|
|
|
|
|
|
|
|
|
// Chart 1: Kcal vs Weight
|
|
|
|
|
|
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
|
|
|
|
|
|
|
|
|
|
|
|
// Chart 2: Protein vs Lean Mass (only days with both)
|
|
|
|
|
|
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
|
|
|
|
|
|
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
|
|
|
|
|
|
|
|
|
|
|
|
// Chart 3: Activity kcal vs Weight change
|
|
|
|
|
|
const actVsW = filtered.filter(d=>d.weight)
|
|
|
|
|
|
.map((d,i,arr)=>{
|
|
|
|
|
|
const prev = arr[i-1]
|
|
|
|
|
|
return {
|
|
|
|
|
|
date: fmtDate(d.date),
|
|
|
|
|
|
weight: d.weight,
|
|
|
|
|
|
weightDelta: prev ? Math.round((d.weight-prev.weight)*10)/10 : null,
|
|
|
|
|
|
kcal: d.kcal||0,
|
|
|
|
|
|
}
|
|
|
|
|
|
}).filter(d=>d.weightDelta!==null)
|
|
|
|
|
|
|
|
|
|
|
|
// Chart 4: Calorie balance (intake - estimated TDEE)
|
|
|
|
|
|
const balance = filtered.map(d=>({
|
|
|
|
|
|
date: fmtDate(d.date),
|
|
|
|
|
|
balance: Math.round((d.kcal||0) - tdee),
|
|
|
|
|
|
}))
|
|
|
|
|
|
const balWithAvg = rollingAvg(balance,'balance')
|
|
|
|
|
|
const avgBalance = Math.round(balance.reduce((s,d)=>s+d.balance,0)/balance.length)
|
|
|
|
|
|
|
|
|
|
|
|
// ── Correlation insights ──
|
|
|
|
|
|
const corrInsights = []
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Kcal → Weight correlation
|
|
|
|
|
|
if (filtered.length >= 14) {
|
|
|
|
|
|
const highKcal = filtered.filter(d=>d.kcal>tdee+200)
|
|
|
|
|
|
const lowKcal = filtered.filter(d=>d.kcal<tdee-200)
|
|
|
|
|
|
if (highKcal.length>=3 && lowKcal.length>=3) {
|
|
|
|
|
|
const avgWHigh = Math.round(highKcal.reduce((s,d)=>s+d.weight,0)/highKcal.length*10)/10
|
|
|
|
|
|
const avgWLow = Math.round(lowKcal.reduce((s,d)=>s+d.weight,0)/lowKcal.length*10)/10
|
|
|
|
|
|
corrInsights.push({
|
|
|
|
|
|
icon:'📊', status: avgWLow < avgWHigh ? 'good' : 'warn',
|
|
|
|
|
|
title: avgWLow < avgWHigh
|
|
|
|
|
|
? `Kalorienreduktion wirkt: Ø ${avgWLow}kg bei Defizit vs. ${avgWHigh}kg bei Überschuss`
|
|
|
|
|
|
: `Kein klarer Kalorieneffekt auf Gewicht erkennbar`,
|
|
|
|
|
|
detail: `Tage mit Überschuss (>${tdee+200} kcal): Ø ${avgWHigh}kg · Tage mit Defizit (<${tdee-200} kcal): Ø ${avgWLow}kg`,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Protein → Lean mass
|
|
|
|
|
|
if (protVsLean.length >= 3) {
|
|
|
|
|
|
const ptLow = Math.round(latestW*1.6)
|
|
|
|
|
|
const highProt = protVsLean.filter(d=>d.protein>=ptLow)
|
|
|
|
|
|
const lowProt = protVsLean.filter(d=>d.protein<ptLow)
|
|
|
|
|
|
if (highProt.length>=2 && lowProt.length>=2) {
|
|
|
|
|
|
const avgLH = Math.round(highProt.reduce((s,d)=>s+d.lean,0)/highProt.length*10)/10
|
|
|
|
|
|
const avgLL = Math.round(lowProt.reduce((s,d)=>s+d.lean,0)/lowProt.length*10)/10
|
|
|
|
|
|
corrInsights.push({
|
|
|
|
|
|
icon:'🥩', status: avgLH >= avgLL ? 'good' : 'warn',
|
|
|
|
|
|
title: `Hohe Proteinzufuhr (≥${ptLow}g): Ø ${avgLH}kg Mager · Niedrig: Ø ${avgLL}kg`,
|
|
|
|
|
|
detail: `${highProt.length} Messpunkte mit hoher vs. ${lowProt.length} mit niedriger Proteinzufuhr verglichen.`,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Avg balance
|
|
|
|
|
|
corrInsights.push({
|
|
|
|
|
|
icon: avgBalance < -100 ? '✅' : avgBalance > 200 ? '⬆️' : '➡️',
|
|
|
|
|
|
status: avgBalance < -100 ? 'good' : avgBalance > 300 ? 'warn' : 'good',
|
|
|
|
|
|
title: `Ø Kalorienbilanz: ${avgBalance>0?'+':''}${avgBalance} kcal/Tag`,
|
|
|
|
|
|
detail: `Geschätzter TDEE: ${tdee} kcal (Mifflin-St Jeor ×1,4). ${
|
|
|
|
|
|
avgBalance<-500?'Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen.':
|
|
|
|
|
|
avgBalance<-100?'Moderates Defizit – ideal für Fettabbau bei Muskelerhalt.':
|
|
|
|
|
|
avgBalance>300?'Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich.':
|
|
|
|
|
|
'Nahezu ausgeglichen – Gewicht sollte stabil bleiben.'}`,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<SectionHeader title="🔗 Korrelationen"/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Chart 1: Kcal vs Weight */}
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
|
|
|
|
|
📉 Kalorien (Ø 7T) vs. Gewicht
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={190}>
|
|
|
|
|
|
<LineChart data={kcalVsW} margin={{top:4,right:8,bottom:0,left:-16}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
|
|
|
|
interval={Math.max(0,Math.floor(kcalVsW.length/6)-1)}/>
|
|
|
|
|
|
<YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
|
|
|
|
|
<YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
|
|
|
|
formatter={(v,n)=>[`${Math.round(v)} ${n==='weight'?'kg':'kcal'}`,n==='kcal_avg'?'Ø Kalorien':'Gewicht']}/>
|
|
|
|
|
|
<ReferenceLine yAxisId="kcal" y={tdee} stroke="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
|
|
|
|
|
|
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
|
|
|
|
|
|
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
|
|
|
|
|
|
</LineChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
|
|
|
|
|
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}>— Kalorien</span> · <span style={{color:'#378ADD'}}>— Gewicht</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Chart 2: Calorie balance */}
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
|
|
|
|
|
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
|
|
|
|
<LineChart data={balWithAvg} margin={{top:4,right:8,bottom:0,left:-16}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
|
|
|
|
interval={Math.max(0,Math.floor(balWithAvg.length/6)-1)}/>
|
|
|
|
|
|
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
|
|
|
|
formatter={(v,n)=>[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
|
|
|
|
|
|
<Line type="monotone" dataKey="balance" stroke="#EF9F2744" strokeWidth={1} dot={false}/>
|
|
|
|
|
|
<Line type="monotone" dataKey="balance_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_avg"/>
|
|
|
|
|
|
</LineChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
|
|
|
|
|
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Chart 3: Protein vs Lean Mass */}
|
|
|
|
|
|
{protVsLean.length >= 3 && (
|
|
|
|
|
|
<div className="card" style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
|
|
|
|
|
🥩 Protein vs. Magermasse
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={160}>
|
|
|
|
|
|
<LineChart data={protVsLean} margin={{top:4,right:8,bottom:0,left:-16}}>
|
|
|
|
|
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
|
|
|
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
|
|
|
|
|
<YAxis yAxisId="prot" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
|
|
|
|
|
<YAxis yAxisId="lean" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>
|
|
|
|
|
|
<ReferenceLine yAxisId="prot" y={Math.round(latestW*1.6)} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
|
|
|
|
|
label={{value:`Ziel ${Math.round(latestW*1.6)}g`,fontSize:9,fill:'#1D9E75',position:'right'}}/>
|
|
|
|
|
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
|
|
|
|
formatter={(v,n)=>[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
|
|
|
|
|
|
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein"/>
|
|
|
|
|
|
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{r:4,fill:'#7F77DD'}} name="lean"/>
|
|
|
|
|
|
</LineChart>
|
|
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
|
|
|
|
|
|
<span style={{color:'#1D9E75'}}>— Protein g/Tag</span> · <span style={{color:'#7F77DD'}}>● Magermasse kg</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Correlation insights */}
|
|
|
|
|
|
{corrInsights.length > 0 && (
|
|
|
|
|
|
<div style={{marginBottom:12}}>
|
|
|
|
|
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>KORRELATIONSAUSSAGEN</div>
|
|
|
|
|
|
{corrInsights.map((item,i) => (
|
|
|
|
|
|
<div key={i} style={{padding:'10px 12px',borderRadius:8,marginBottom:6,
|
|
|
|
|
|
background:item.status==='good'?'var(--accent-light)':'var(--warn-bg)',
|
|
|
|
|
|
border:`1px solid ${item.status==='good'?'var(--accent)':'var(--warn)'}33`}}>
|
|
|
|
|
|
<div style={{display:'flex',gap:8,alignItems:'flex-start'}}>
|
|
|
|
|
|
<span style={{fontSize:16}}>{item.icon}</span>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div style={{fontSize:13,fontWeight:600}}>{item.title}</div>
|
|
|
|
|
|
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>{item.detail}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<div style={{fontSize:11,color:'var(--text3)',padding:'6px 10px',background:'var(--surface2)',
|
|
|
|
|
|
borderRadius:6,marginTop:6}}>
|
|
|
|
|
|
ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-19 07:47:59 +01:00
|
|
|
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Photo Grid ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
function PhotoGrid() {
|
2026-04-19 09:20:28 +02:00
|
|
|
|
const [photos, setPhotos] = useState([])
|
|
|
|
|
|
const [big, setBig] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
const load = () => api.listPhotos().then(setPhotos)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
load()
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = async (id) => {
|
|
|
|
|
|
if (!confirm('Dieses Foto löschen?')) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deletePhoto(id)
|
|
|
|
|
|
if (big === id) setBig(null)
|
|
|
|
|
|
await load()
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
alert(e.message || 'Löschen fehlgeschlagen')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!photos.length) {
|
|
|
|
|
|
return <EmptySection text="Noch keine Fotos." to="/photos" toLabel="Fotos erfassen" />
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 10:13:22 +02:00
|
|
|
|
const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a)))
|
2026-04-19 09:20:28 +02:00
|
|
|
|
const byMonth = new Map()
|
|
|
|
|
|
for (const p of sorted) {
|
2026-04-19 10:13:22 +02:00
|
|
|
|
const mk = photoMonthKey(p)
|
2026-04-19 09:20:28 +02:00
|
|
|
|
if (!byMonth.has(mk)) byMonth.set(mk, [])
|
|
|
|
|
|
byMonth.get(mk).push(p)
|
|
|
|
|
|
}
|
|
|
|
|
|
const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a))
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-04-19 09:20:28 +02:00
|
|
|
|
{big && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
inset: 0,
|
|
|
|
|
|
background: 'rgba(0,0,0,0.9)',
|
|
|
|
|
|
zIndex: 100,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={() => setBig(null)}
|
|
|
|
|
|
role="presentation"
|
|
|
|
|
|
>
|
|
|
|
|
|
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{monthKeys.map((mk) => (
|
|
|
|
|
|
<div key={mk} style={{ marginBottom: 16 }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
color: 'var(--text3)',
|
|
|
|
|
|
marginBottom: 10,
|
|
|
|
|
|
textTransform: 'capitalize',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{dayjs(`${mk}-01`).format('MMMM YYYY')}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-19 09:20:28 +02:00
|
|
|
|
<div className="photo-grid">
|
|
|
|
|
|
{byMonth.get(mk).map((p) => (
|
|
|
|
|
|
<div key={p.id} style={{ position: 'relative' }}>
|
|
|
|
|
|
<img
|
|
|
|
|
|
src={api.photoUrl(p.id)}
|
|
|
|
|
|
className="photo-thumb"
|
|
|
|
|
|
onClick={() => setBig(p.id)}
|
|
|
|
|
|
alt=""
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
bottom: 4,
|
|
|
|
|
|
left: 4,
|
|
|
|
|
|
fontSize: 9,
|
|
|
|
|
|
background: 'rgba(0,0,0,0.6)',
|
|
|
|
|
|
color: 'white',
|
|
|
|
|
|
padding: '1px 4px',
|
|
|
|
|
|
borderRadius: 3,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-19 10:13:22 +02:00
|
|
|
|
{formatPhotoCaption(p)}
|
2026-04-19 09:20:28 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className="btn btn-secondary"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
top: 4,
|
|
|
|
|
|
right: 4,
|
|
|
|
|
|
padding: 6,
|
|
|
|
|
|
minWidth: 0,
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
}}
|
|
|
|
|
|
title="Löschen"
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
handleDelete(p.id)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
2026-03-29 07:02:54 +02:00
|
|
|
|
// ── Recovery Section ──────────────────────────────────────────────────────────
|
|
|
|
|
|
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
|
|
|
|
|
const [period, setPeriod] = useState(28)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<SectionHeader title="😴 Erholung & Vitalwerte" to="/vitals" toLabel="Daten"/>
|
|
|
|
|
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{marginBottom:12,fontSize:13,color:'var(--text2)',lineHeight:1.6}}>
|
|
|
|
|
|
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Recovery Charts (Phase 0c) */}
|
|
|
|
|
|
<RecoveryCharts days={period === 9999 ? 90 : period} />
|
|
|
|
|
|
|
|
|
|
|
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesundheit'])} onRequest={onRequest} loading={loadingSlug}/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const TABS = [
|
|
|
|
|
|
{ id:'body', label:'⚖️ Körper' },
|
|
|
|
|
|
{ id:'nutrition', label:'🍽️ Ernährung' },
|
|
|
|
|
|
{ id:'activity', label:'🏋️ Aktivität' },
|
2026-03-29 07:02:54 +02:00
|
|
|
|
{ id:'recovery', label:'😴 Erholung' },
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{ id:'correlation', label:'🔗 Korrelation' },
|
|
|
|
|
|
{ id:'photos', label:'📷 Fotos' },
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
export default function History() {
|
2026-03-23 22:29:49 +01:00
|
|
|
|
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const location = useLocation?.() || {}
|
|
|
|
|
|
const [tab, setTab] = useState((location.state?.tab)||'body')
|
|
|
|
|
|
const [weights, setWeights] = useState([])
|
|
|
|
|
|
const [calipers, setCalipers] = useState([])
|
|
|
|
|
|
const [circs, setCircs] = useState([])
|
|
|
|
|
|
const [nutrition, setNutrition] = useState([])
|
|
|
|
|
|
const [activities, setActivities] = useState([])
|
|
|
|
|
|
const [corrData, setCorrData] = useState([])
|
|
|
|
|
|
const [insights, setInsights] = useState([])
|
2026-03-19 07:47:59 +01:00
|
|
|
|
const [prompts, setPrompts] = useState([])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [profile, setProfile] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [loadingSlug,setLoadingSlug]= useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
const loadAll = () => Promise.all([
|
|
|
|
|
|
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
2026-04-07 12:28:59 +02:00
|
|
|
|
api.listNutrition(90), api.listActivity(25_000),
|
2026-03-16 13:35:11 +01:00
|
|
|
|
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
2026-03-19 07:47:59 +01:00
|
|
|
|
api.listPrompts(),
|
|
|
|
|
|
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
2026-03-16 13:35:11 +01:00
|
|
|
|
setWeights(w); setCalipers(ca); setCircs(ci)
|
|
|
|
|
|
setNutrition(n); setActivities(a); setCorrData(corr)
|
|
|
|
|
|
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
2026-03-19 07:47:59 +01:00
|
|
|
|
setPrompts(Array.isArray(pr)?pr:[])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
setLoading(false)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-04-07 12:28:59 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadAll()
|
|
|
|
|
|
}, [activeProfile?.quality_filter_level])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-05 08:46:37 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const t = location.state?.tab
|
|
|
|
|
|
if (t && TABS.some(x => x.id === t)) setTab(t)
|
|
|
|
|
|
}, [location.state?.tab])
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const requestInsight = async (slug) => {
|
|
|
|
|
|
setLoadingSlug(slug)
|
|
|
|
|
|
try {
|
2026-03-19 07:47:59 +01:00
|
|
|
|
const result = await api.runInsight(slug)
|
|
|
|
|
|
// result is already JSON, not a Response object
|
|
|
|
|
|
const ins = await api.latestInsights()
|
2026-03-16 13:35:11 +01:00
|
|
|
|
setInsights(Array.isArray(ins)?ins:[])
|
2026-03-19 07:47:59 +01:00
|
|
|
|
} catch(e){
|
|
|
|
|
|
alert('KI-Fehler: '+e.message)
|
|
|
|
|
|
}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
finally{ setLoadingSlug(null) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if(loading) return <div className="empty-state"><div className="spinner"/></div>
|
2026-03-19 07:47:59 +01:00
|
|
|
|
|
|
|
|
|
|
// Filter active prompts
|
|
|
|
|
|
const activeSlugs = prompts.filter(p=>p.active).map(p=>p.slug)
|
|
|
|
|
|
const filterActiveSlugs = (slugs) => slugs.filter(s=>activeSlugs.includes(s))
|
|
|
|
|
|
|
|
|
|
|
|
const sp={insights,onRequest:requestInsight,loadingSlug,filterActiveSlugs}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-04-05 08:46:37 +02:00
|
|
|
|
<div className="history-page">
|
|
|
|
|
|
<h1 className="page-title history-page__title">Verlauf & Auswertung</h1>
|
|
|
|
|
|
<div className="history-page__layout">
|
|
|
|
|
|
<nav className="history-tabs" aria-label="Verlauf-Kategorien">
|
|
|
|
|
|
<div className="history-tabs__scroller">
|
|
|
|
|
|
{TABS.map(t => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={t.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`history-tab-btn${tab === t.id ? ' history-tab-btn--active' : ''}`}
|
|
|
|
|
|
onClick={() => setTab(t.id)}
|
|
|
|
|
|
aria-current={tab === t.id ? 'page' : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{t.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
<div className="history-content">
|
2026-04-19 16:06:07 +02:00
|
|
|
|
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
2026-04-05 08:46:37 +02:00
|
|
|
|
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} {...sp}/>}
|
|
|
|
|
|
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
|
|
|
|
|
{tab==='recovery' && <RecoverySection {...sp}/>}
|
|
|
|
|
|
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
|
|
|
|
|
{tab==='photos' && <PhotoGrid/>}
|
|
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|