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 } 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, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview'
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
}
function NavToCircum() {
const nav = useNavigate()
return
}
function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
🤖 KI-AUSWERTUNGEN
{slugs.map(slug=>(
{relevant.length===0 && (
Noch keine Auswertung. Klicke oben um eine zu erstellen.
)}
{relevant.map(ins=>(
setExpanded(expanded===ins.id?null:ins.id)}>
{dayjs(ins.created).format('DD. MMM YYYY, HH:mm')} · {LABELS[ins.scope]||ins.scope}
{expanded===ins.id?
:}
{expanded===ins.id &&
}
))}
)
}
// ── 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 (
{opts.map(o=>(
))}
)
}
// ── 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 (
)
}
if (vizError) {
return (
)
}
if (!hasAnyData) {
return (
)
}
return (
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: Verlauf → Aktivität.
{viz?.meta?.layer_2a_alignment && (
{viz.meta.layer_2a_alignment}
)}
{vizLoading && (
Aktualisiere…
)}
{hasWeight && (
Gewicht · {w?.data_points || 0} Einträge
{avgAll != null && (
)}
{goalW != null && (
)}
[`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
● Täglich
Ø 7T
Ø 14T
Ø Gesamt
)}
{bfCd.length >= 2 && (
[`${v}%`, 'KF%']} />
{goalBf != null && }
Magermasse aus Gewicht und KF% — zweite Kurve entfällt.
)}
{propChartData.length >= 2 && (
Silhouette & Proportion
V-Taper (Brust − Taille) in cm.
{showBellyOnProp && <> Bauch (rechte Achse).>}
{showBellyOnProp && }
{
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]
}}
/>
{showBellyOnProp && }
Brust − Taille
gleitender Mittelwert
{showBellyOnProp && Bauch (cm)}
)}
{idxOk && (
Relative Entwicklung der Umfänge
Index 100 = erste Messung im Zeitraum.
[`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
{idxSeries.some(d => d.chest_idx != null) && }
{idxSeries.some(d => d.waist_idx != null) && }
{idxSeries.some(d => d.belly_idx != null) && }
Brust
Taille
Bauch
)}
{propChartData.length < 2 && cirCd.length >= 2 && (
Umfänge (Taille / Hüfte / Bauch)
Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.
[`${v} cm`, n]} />
{cirCd.some(d => d.belly) && }
)}
)
}
function buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
}) {
const tiles = [
{
key: 'kcal',
category: 'Kalorien (Ø)',
icon: '🔥',
value: `${avgKcal} kcal`,
sublabel: dateSpanLabel,
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche tägliche Energie',
hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`,
},
{
key: 'carbs',
category: 'KH (Ø)',
icon: '🌾',
value: `${avgCarbs} g`,
sublabel: 'Kohlenhydrate / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche Kohlenhydrate',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
{
key: 'fat',
category: 'Fett (Ø)',
icon: '🧈',
value: `${avgFat} g`,
sublabel: 'Fett / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliches Fett',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
]
macroRules.forEach((r, i) => {
tiles.push({
key: `eval-${i}`,
category: r.category,
icon: r.icon,
value: r.value,
sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}…` : r.title,
status: r.status,
verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'),
hoverTop: r.title,
hoverBody: r.detail,
})
})
return tiles
}
/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */
function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) {
const raw = (corrRows || []).filter(d => {
if (!d.kcal || d.weight == null) return false
const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD')
return allTime || ds >= cutoffDate
})
if (raw.length < 5) return null
const sex = profile?.sex || 'm'
const height = profile?.height || 178
const latestW = raw[raw.length - 1]?.weight || 80
const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35
const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161
const tdee = Math.round(bmr * 1.4)
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
return (
Kalorien (Ø 7 Tage) vs. Gewicht
Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht.
[`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage
)
}
// ── Nutrition Section ─────────────────────────────────────────────────────────
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) {
const [period, setPeriod] = useState(30)
const [groupedGoals, setGroupedGoals] = useState(null)
const chartDays = period === 9999 ? 90 : period
const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7)))
const [weeklyMacro, setWeeklyMacro] = useState(null)
const [wmLoading, setWmLoading] = useState(false)
const [wmError, setWmError] = useState(null)
useEffect(() => {
let cancelled = false
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
setWmLoading(true)
setWmError(null)
api.getWeeklyMacroDistributionChart(weeks)
.then(d => { if (!cancelled) setWeeklyMacro(d) })
.catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') })
.finally(() => { if (!cancelled) setWmLoading(false) })
return () => { cancelled = true }
}, [weeks])
if (!nutrition?.length) {
return (
)
}
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 (
)
}
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
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),
}))
const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9
const pieData = [
{ name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' },
{ name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' },
{ name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' },
]
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: ~${Math.max(0, 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 oft 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`,
value: `${protPct}%`,
})
}
const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} – ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}`
const kpiTiles = buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
})
return (
Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
Kalorien vs. Gewicht bezieht gemeinsame Tage aus Ernährung und Gewicht.
Makroverteilung täglich (g) · Fokus Protein
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht).
[`${v}g`, name]} />
Protein (oben)
KH
Fett
Ø Makro-Quote ({n} Tage)
{pieData.map((e, i) => | )}
[`${v}%`, name]} />
{pieData.map(p => (
{p.name}
{p.value}%
{Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g
))}
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
Wöchentliche Makro-Verteilung (Backend)
Zeitverläufe (Energie & Protein)
)
}
// ── Activity Section ──────────────────────────────────────────────────────────
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30)
if (!activities?.length) return (
)
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 (
{/* Issue #31: Show active global quality filter */}
{globalQualityLevel && globalQualityLevel !== 'all' && (
{globalQualityLevel === 'quality' && '✓ Filter: Hochwertig (excellent, good, acceptable)'}
{globalQualityLevel === 'very_good' && '✓✓ Filter: Sehr gut (excellent, good)'}
{globalQualityLevel === 'excellent' && '⭐ Filter: Exzellent (nur excellent)'}
Hier ändern →
)}
{[['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])=>(
))}
Aktive Kalorien / Tag
[`${v} kcal`]}/>
Trainingsarten
{topTypes.map(([type,count])=>(
BEWERTUNG
{actRules.map((item,i)=>
)}
)
}
// ── Correlation Section ───────────────────────────────────────────────────────
function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlug, filterActiveSlugs }) {
const filtered = (corrData||[]).filter(d=>d.kcal&&d.weight)
if (filtered.length < 5) return (
)
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
// 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=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=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 (
Das Diagramm Kalorien (Ø 7T) vs. Gewicht liegt unter Verlauf → Ernährung (gleiche Datenbasis).
{/* Chart: Calorie balance */}
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
[`${v>0?'+':''}${v} kcal`,n==='balance_avg'?'Ø 7T Bilanz':'Tagesbilanz']}/>
Über 0 = Überschuss · Unter 0 = Defizit · Ø {avgBalance>0?'+':''}{avgBalance} kcal/Tag
{/* Chart 3: Protein vs Lean Mass */}
{protVsLean.length >= 3 && (
🥩 Protein vs. Magermasse
[`${v}${n==='protein'?'g':' kg'}`,n==='protein'?'Protein':'Mager']}/>
— Protein g/Tag · ● Magermasse kg
)}
{/* Correlation insights */}
{corrInsights.length > 0 && (
KORRELATIONSAUSSAGEN
{corrInsights.map((item,i) => (
{item.icon}
{item.title}
{item.detail}
))}
ℹ️ TDEE-Schätzung basiert auf Mifflin-St Jeor ×1,4 (leicht aktiv). Für genauere Werte Aktivitätsdaten erfassen.
)}
)
}
// ── 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
}
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 && (
setBig(null)}
role="presentation"
>
)}
{monthKeys.map((mk) => (
{dayjs(`${mk}-01`).format('MMMM YYYY')}
{byMonth.get(mk).map((p) => (
})
setBig(p.id)}
alt=""
/>
{formatPhotoCaption(p)}
))}
))}
>
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
// ── Recovery Section ──────────────────────────────────────────────────────────
function RecoverySection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(28)
return (
Erholung, Schlaf, HRV, Ruhepuls und weitere Vitalwerte im Überblick.
{/* Recovery Charts (Phase 0c) */}
)
}
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
// 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 (
Verlauf & Auswertung
{tab==='body' &&
}
{tab==='nutrition' &&
}
{tab==='activity' &&
}
{tab==='recovery' &&
}
{tab==='correlation' &&
}
{tab==='photos' &&
}
)
}