- Added a new button for displaying KPI details on touch devices, replacing hover functionality. - Introduced a bottom sheet component to present detailed information when the info button is clicked. - Enhanced the BodyKpiOverview component to detect touch UI and adjust interactions accordingly. - Updated CSS styles for new touch elements, ensuring a responsive and user-friendly design.
1502 lines
71 KiB
JavaScript
1502 lines
71 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useNavigate, useLocation } from 'react-router-dom'
|
||
import { useProfile } from '../context/ProfileContext'
|
||
import {
|
||
LineChart, Line, BarChart, Bar,
|
||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||
ReferenceLine, PieChart, Pie, Cell, ComposedChart
|
||
} from 'recharts'
|
||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react'
|
||
import { api } from '../utils/api'
|
||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||
import { getBfCategory } from '../utils/calc'
|
||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||
import Markdown from '../utils/Markdown'
|
||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||
import NutritionCharts from '../components/NutritionCharts'
|
||
import RecoveryCharts from '../components/RecoveryCharts'
|
||
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>
|
||
)
|
||
}
|
||
|
||
function verdictShort(status) {
|
||
if (status === 'good') return 'Gut'
|
||
if (status === 'warn') return 'Hinweis'
|
||
return 'Achtung'
|
||
}
|
||
|
||
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */
|
||
function buildBodyKpiTiles({
|
||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW,
|
||
}) {
|
||
const tiles = []
|
||
|
||
if (summary.weight_kg != null) {
|
||
const t90 = trendPeriods.find(t => t.label === '90T')
|
||
const t30 = trendPeriods.find(t => t.label === '30T')
|
||
const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg
|
||
let st = 'good'
|
||
let vs = 'Stabil'
|
||
if (d != null) {
|
||
if (d < -0.25) { st = 'good'; vs = 'Trend ↓' }
|
||
else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' }
|
||
else { st = 'good'; vs = 'Stabil' }
|
||
}
|
||
const trendBits = trendPeriods.length
|
||
? trendPeriods.map(t => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
||
: ''
|
||
const hoverBody = [
|
||
'Gewicht im gewählten Zeitraum (letzter Messwert).',
|
||
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
|
||
minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null,
|
||
trendBits ? `Änderung: ${trendBits}` : null,
|
||
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
|
||
].filter(Boolean).join('\n')
|
||
|
||
tiles.push({
|
||
key: 'weight',
|
||
category: 'Gewicht',
|
||
icon: '⚖️',
|
||
value: `${summary.weight_kg} kg`,
|
||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||
verdict: vs,
|
||
status: st,
|
||
hoverTop: 'Gewicht',
|
||
hoverBody,
|
||
keys: ['weight_aktuell', 'weight_trend'],
|
||
})
|
||
}
|
||
|
||
const kfRule = rules.find(r => r.category === 'Körperfett')
|
||
if (summary.body_fat_pct != null) {
|
||
tiles.push({
|
||
key: 'bf',
|
||
category: 'Körperfett',
|
||
icon: '🫧',
|
||
value: `${summary.body_fat_pct}%`,
|
||
valueColor: bfCat?.color,
|
||
sublabel: bfCat?.label || summary.bf_category_label || '',
|
||
verdict: verdictShort(kfRule?.status || 'good'),
|
||
status: kfRule?.status || 'good',
|
||
hoverTop: kfRule?.title || 'Körperfettanteil',
|
||
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
const mmRule = rules.find(r => r.category === 'Muskelmasse')
|
||
if (summary.lean_mass_kg != null || summary.ffmi != null) {
|
||
const valParts = []
|
||
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
|
||
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
|
||
tiles.push({
|
||
key: 'lean_ffmi',
|
||
category: 'Magermasse',
|
||
icon: '💪',
|
||
value: valParts.join(' · ') || '—',
|
||
sublabel: 'Lean / FFMI',
|
||
verdict: mmRule ? verdictShort(mmRule.status) : '—',
|
||
status: mmRule?.status || 'good',
|
||
hoverTop: mmRule?.title || 'Muskelmasse',
|
||
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
const bmiRule = rules.find(r => r.category === 'BMI')
|
||
if (bmiRule) {
|
||
tiles.push({
|
||
key: 'bmi',
|
||
category: 'BMI',
|
||
icon: '📋',
|
||
value: bmiRule.value || '—',
|
||
sublabel: 'Body-Mass-Index',
|
||
verdict: verdictShort(bmiRule.status),
|
||
status: bmiRule.status,
|
||
hoverTop: bmiRule.title,
|
||
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
const whrRule = rules.find(r => r.category === 'Fettverteilung')
|
||
if (summary.whr != null) {
|
||
const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85)
|
||
tiles.push({
|
||
key: 'whr',
|
||
category: 'Fettverteilung',
|
||
icon: '📐',
|
||
value: String(summary.whr),
|
||
sublabel: 'WHR · Taille ÷ Hüfte',
|
||
verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
||
status: whrRule?.status || (ok ? 'good' : 'warn'),
|
||
hoverTop: whrRule?.title || 'Waist-Hip-Ratio',
|
||
hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
const whtrRule = rules.find(r => r.category === 'Taille/Größe')
|
||
if (summary.whtr != null) {
|
||
const ok = summary.whtr < 0.5
|
||
tiles.push({
|
||
key: 'whtr',
|
||
category: 'Taille/Größe',
|
||
icon: '📏',
|
||
value: String(summary.whtr),
|
||
sublabel: 'WHtR · Taille ÷ Größe',
|
||
verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
||
status: whtrRule?.status || (ok ? 'good' : 'warn'),
|
||
hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio',
|
||
hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
const lastRule = rules.find(r => r.category.startsWith('Seit letzter'))
|
||
if (lastRule) {
|
||
tiles.push({
|
||
key: 'delta',
|
||
category: 'Messvergleich',
|
||
icon: '📊',
|
||
value: lastRule.value || '—',
|
||
sublabel: 'seit Vorperiode',
|
||
verdict: verdictShort(lastRule.status),
|
||
status: lastRule.status,
|
||
hoverTop: lastRule.title,
|
||
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||
})
|
||
}
|
||
|
||
return tiles
|
||
}
|
||
|
||
function kpiTileDetailParts(t) {
|
||
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
|
||
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
|
||
return { title: t.hoverTop || t.category, body }
|
||
}
|
||
|
||
/** KPI-Kacheln: Desktop — Hover (`title`). Touch — ℹ öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */
|
||
function BodyKpiOverview({ tiles }) {
|
||
const [touchUi, setTouchUi] = useState(false)
|
||
const [openKey, setOpenKey] = useState(null)
|
||
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(hover: none)')
|
||
const apply = () => setTouchUi(mq.matches)
|
||
apply()
|
||
mq.addEventListener('change', apply)
|
||
return () => mq.removeEventListener('change', apply)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!openKey) return
|
||
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
|
||
const prev = document.body.style.overflow
|
||
document.body.style.overflow = 'hidden'
|
||
window.addEventListener('keydown', onKey)
|
||
return () => {
|
||
document.body.style.overflow = prev
|
||
window.removeEventListener('keydown', onKey)
|
||
}
|
||
}, [openKey])
|
||
|
||
if (!tiles?.length) return null
|
||
|
||
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
|
||
const openParts = openTile ? kpiTileDetailParts(openTile) : null
|
||
|
||
return (
|
||
<div style={{ marginBottom: 12 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
|
||
{touchUi && (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
|
||
Auf dem Smartphone: <strong>ℹ</strong> für Erklärung und Details.
|
||
</div>
|
||
)}
|
||
<div className="body-kpi-overview">
|
||
{tiles.map(t => {
|
||
const accent = getStatusColor(t.status)
|
||
const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n')
|
||
return (
|
||
<div
|
||
key={t.key}
|
||
className="body-kpi-card"
|
||
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
|
||
title={touchUi ? undefined : tip}
|
||
>
|
||
{touchUi && (
|
||
<button
|
||
type="button"
|
||
className="body-kpi-info-btn"
|
||
aria-label={`Details: ${t.category}`}
|
||
aria-expanded={openKey === t.key}
|
||
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
|
||
>
|
||
<Info size={16} strokeWidth={2.25} aria-hidden />
|
||
</button>
|
||
)}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
|
||
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
|
||
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
|
||
{t.sublabel && (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
|
||
)}
|
||
</div>
|
||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{openParts && (
|
||
<div
|
||
className="body-kpi-touch-backdrop"
|
||
role="presentation"
|
||
onClick={() => setOpenKey(null)}
|
||
>
|
||
<div
|
||
className="body-kpi-touch-sheet"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="body-kpi-touch-title"
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div className="body-kpi-touch-sheet__head">
|
||
<h3 id="body-kpi-touch-title" className="body-kpi-touch-sheet__title">{openParts.title}</h3>
|
||
<button type="button" className="body-kpi-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
|
||
×
|
||
</button>
|
||
</div>
|
||
{openParts.body ? (
|
||
<div className="body-kpi-touch-sheet__body">{openParts.body}</div>
|
||
) : (
|
||
<div className="body-kpi-touch-sheet__body body-kpi-touch-sheet__body--muted">Keine weiteren Details.</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BodyGoalsStrip({ grouped }) {
|
||
const nav = useNavigate()
|
||
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
|
||
if (!goals.length) return null
|
||
return (
|
||
<div className="card" style={{ marginBottom: 12 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
|
||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
|
||
Ziele <ChevronRight size={10} />
|
||
</button>
|
||
</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
{goals.map(g => (
|
||
<div
|
||
key={g.id}
|
||
style={{
|
||
flex: '1 1 140px',
|
||
background: 'var(--surface2)',
|
||
borderRadius: 8,
|
||
padding: '8px 10px',
|
||
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
|
||
}}
|
||
>
|
||
<div style={{
|
||
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
|
||
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||
}}>{g.name || g.label_de || g.goal_type}</div>
|
||
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||
<div style={{
|
||
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||
height: '100%',
|
||
background: 'var(--accent)',
|
||
}} />
|
||
</div>
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InsightBox({ insights, slugs, onRequest, loading }) {
|
||
const [expanded, setExpanded] = useState(null)
|
||
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
|
||
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>
|
||
)
|
||
}
|
||
|
||
// ── Body Section — Layer 2b: Daten nur aus GET /api/charts/body-history-viz ──
|
||
function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
const [period, setPeriod] = useState(90)
|
||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||
const [viz, setViz] = useState(null)
|
||
const [vizLoading, setVizLoading] = useState(true)
|
||
const [vizError, setVizError] = useState(null)
|
||
|
||
const sex = profile?.sex || 'm'
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
api.listGoalsGrouped()
|
||
.then(g => { if (!cancelled) setGroupedGoals(g) })
|
||
.catch(() => { if (!cancelled) setGroupedGoals({}) })
|
||
return () => { cancelled = true }
|
||
}, [])
|
||
|
||
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,
|
||
}))
|
||
|
||
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)
|
||
|
||
const idxSeriesRaw = circ?.index_series || []
|
||
const idxSeries = idxSeriesRaw.map(row => ({ ...row, date: fmtDate(row.date) }))
|
||
const idxOk = circ?.index_usable
|
||
|
||
const cirCd = (circ?.fallback_multiline || []).map(r => ({
|
||
date: fmtDate(r.date),
|
||
waist: r.waist,
|
||
hip: r.hip,
|
||
belly: r.belly,
|
||
}))
|
||
|
||
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,
|
||
}))
|
||
|
||
const kpiTiles = buildBodyKpiTiles({
|
||
summary,
|
||
rules,
|
||
trendPeriods,
|
||
minW,
|
||
maxW,
|
||
avgAll,
|
||
dataPoints: w?.data_points,
|
||
sex,
|
||
bfCat,
|
||
goalW,
|
||
})
|
||
|
||
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>
|
||
)
|
||
}
|
||
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>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />
|
||
<PeriodSelector value={period} onChange={setPeriod} />
|
||
|
||
<BodyGoalsStrip grouped={groupedGoals} />
|
||
|
||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Aktivität</strong>.
|
||
</p>
|
||
|
||
{viz?.meta?.layer_2a_alignment && (
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||
{viz.meta.layer_2a_alignment}
|
||
</div>
|
||
)}
|
||
|
||
<BodyKpiOverview tiles={kpiTiles} />
|
||
|
||
{vizLoading && (
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
||
)}
|
||
|
||
{hasWeight && (
|
||
<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
|
||
</div>
|
||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
|
||
Daten <ChevronRight size={10} />
|
||
</button>
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={200}>
|
||
<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" />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
|
||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#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>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{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 />
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={170}>
|
||
<LineChart data={bfCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||
<YAxis 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" />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% — zweite Kurve entfällt.</div>
|
||
</div>
|
||
)}
|
||
|
||
{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.
|
||
{showBellyOnProp && <><strong> Bauch</strong> (rechte Achse).</>}
|
||
</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>
|
||
)}
|
||
|
||
{idxOk && (
|
||
<div className="card" style={{ marginBottom: 12 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||
<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 Messung im Zeitraum.</div>
|
||
</div>
|
||
<NavToCircum />
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={180}>
|
||
<LineChart data={idxSeries} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
|
||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === '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" />}
|
||
</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>
|
||
)}
|
||
|
||
{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 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
|
||
<ResponsiveContainer width="100%" height={150}>
|
||
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||
<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" />}
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
|
||
</div>
|
||
)
|
||
}
|
||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
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>
|
||
|
||
{/* 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>
|
||
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Activity Section ──────────────────────────────────────────────────────────
|
||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||
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')
|
||
|
||
// Issue #31: Backend already filters by global quality level - only filter by period here
|
||
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
|
||
|
||
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}/>
|
||
|
||
{/* 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>
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
<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>
|
||
<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>
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Correlation Section ───────────────────────────────────────────────────────
|
||
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
|
||
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>
|
||
)}
|
||
|
||
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt','ziele'])} onRequest={onRequest} loading={loadingSlug}/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Photo Grid ────────────────────────────────────────────────────────────────
|
||
function PhotoGrid() {
|
||
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" />
|
||
}
|
||
|
||
const sorted = [...photos].sort((a, b) => photoSortKey(b).localeCompare(photoSortKey(a)))
|
||
const byMonth = new Map()
|
||
for (const p of sorted) {
|
||
const mk = photoMonthKey(p)
|
||
if (!byMonth.has(mk)) byMonth.set(mk, [])
|
||
byMonth.get(mk).push(p)
|
||
}
|
||
const monthKeys = [...byMonth.keys()].sort((a, b) => b.localeCompare(a))
|
||
|
||
return (
|
||
<>
|
||
{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')}
|
||
</div>
|
||
<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,
|
||
}}
|
||
>
|
||
{formatPhotoCaption(p)}
|
||
</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>
|
||
))}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||
// ── 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>
|
||
)
|
||
}
|
||
|
||
const TABS = [
|
||
{ id:'body', label:'⚖️ Körper' },
|
||
{ id:'nutrition', label:'🍽️ Ernährung' },
|
||
{ id:'activity', label:'🏋️ Aktivität' },
|
||
{ id:'recovery', label:'😴 Erholung' },
|
||
{ id:'correlation', label:'🔗 Korrelation' },
|
||
{ id:'photos', label:'📷 Fotos' },
|
||
]
|
||
|
||
export default function History() {
|
||
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
||
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([])
|
||
const [prompts, setPrompts] = useState([])
|
||
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(),
|
||
api.listNutrition(90), api.listActivity(25_000),
|
||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
||
api.listPrompts(),
|
||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
||
setWeights(w); setCalipers(ca); setCircs(ci)
|
||
setNutrition(n); setActivities(a); setCorrData(corr)
|
||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||
setPrompts(Array.isArray(pr)?pr:[])
|
||
setLoading(false)
|
||
})
|
||
|
||
useEffect(() => {
|
||
loadAll()
|
||
}, [activeProfile?.quality_filter_level])
|
||
|
||
useEffect(() => {
|
||
const t = location.state?.tab
|
||
if (t && TABS.some(x => x.id === t)) setTab(t)
|
||
}, [location.state?.tab])
|
||
|
||
const requestInsight = async (slug) => {
|
||
setLoadingSlug(slug)
|
||
try {
|
||
const result = await api.runInsight(slug)
|
||
// result is already JSON, not a Response object
|
||
const ins = await api.latestInsights()
|
||
setInsights(Array.isArray(ins)?ins:[])
|
||
} catch(e){
|
||
alert('KI-Fehler: '+e.message)
|
||
}
|
||
finally{ setLoadingSlug(null) }
|
||
}
|
||
|
||
if(loading) return <div className="empty-state"><div className="spinner"/></div>
|
||
|
||
// 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}
|
||
|
||
return (
|
||
<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">
|
||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||
{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>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|