diff --git a/frontend/src/app.css b/frontend/src/app.css
index 899e532..fa1b72a 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -199,6 +199,30 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
+/* Körper-Verlauf: kompakte Bewertungs-Kacheln */
+.body-eval-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
+ gap: 8px;
+}
+.body-eval-tile {
+ display: block;
+ width: 100%;
+ background: var(--surface2);
+ border-radius: 10px;
+ padding: 8px 10px;
+ cursor: pointer;
+ border: 1px solid var(--border);
+ font: inherit;
+ color: inherit;
+ text-align: left;
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
+}
+.body-eval-tile:hover {
+ border-color: var(--border2);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+}
+
.history-page__title {
margin-bottom: 12px;
}
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx
index 8a8e2b4..1c7d6c4 100644
--- a/frontend/src/pages/History.jsx
+++ b/frontend/src/pages/History.jsx
@@ -4,12 +4,12 @@ import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
- ReferenceLine, PieChart, Pie, Cell
+ ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
-import { getBfCategory } from '../utils/calc'
+import { getBfCategory, calcDerived } from '../utils/calc'
import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
@@ -85,6 +85,93 @@ function RuleCard({ item }) {
)
}
+/** Kompakte Bewertungs-Kacheln (z. B. Körper-Verlauf) */
+function EvaluationTileGrid({ items }) {
+ const [open, setOpen] = useState(null)
+ if (!items?.length) return null
+ return (
+
+
BEWERTUNG
+
+ {items.map((item, i) => {
+ const color = getStatusColor(item.status)
+ const expanded = open === i
+ return (
+
+ )
+ })}
+
+
+ )
+}
+
+function BodyGoalsStrip({ grouped }) {
+ const nav = useNavigate()
+ const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
+ if (!goals.length) return null
+ return (
+
+
+
Körperbezogene Ziele
+
+
+
+ {goals.map(g => (
+
+
{g.name || g.label_de || g.goal_type}
+
+
+ {Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
+
+
+ ))}
+
+
+ )
+}
+
function InsightBox({ insights, slugs, onRequest, loading }) {
const [expanded, setExpanded] = useState(null)
const relevant = insights?.filter(i=>slugs.includes(i.scope))||[]
@@ -154,9 +241,18 @@ function PeriodSelector({ value, onChange }) {
// ── Body Section (Weight + Composition combined) ──────────────────────────────
function BodySection({ weights, calipers, circs, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(90)
+ const [groupedGoals, setGroupedGoals] = useState(null)
const sex = profile?.sex||'m'
const height = profile?.height||178
+ useEffect(() => {
+ let cancelled = false
+ api.listGoalsGrouped()
+ .then(g => { if (!cancelled) setGroupedGoals(g) })
+ .catch(() => { if (!cancelled) setGroupedGoals({}) })
+ return () => { cancelled = true }
+ }, [])
+
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
const filtW = [...(weights||[])].sort((a,b)=>a.date.localeCompare(b.date))
.filter(d=>period===9999||d.date>=cutoff)
@@ -197,9 +293,9 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
return {label:`${days}T`,diff,count:per.length}
}).filter(Boolean)
- // ── Caliper chart ──
+ // ── Caliper: nur KF% (Magermasse ist daraus abgeleitet — eigene zweite Achse entfällt) ──
const bfCd = [...filtCal].filter(c=>c.body_fat_pct).reverse().map(c=>({
- date:fmtDate(c.date),bf:c.body_fat_pct,lean:c.lean_mass,fat:c.fat_mass
+ date:fmtDate(c.date), bf:c.body_fat_pct,
}))
const latestCal = filtCal[0]
const prevCal = filtCal[1]
@@ -207,11 +303,37 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
const latestW2 = filtW[filtW.length-1]
const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
- // ── Circ chart ──
+ // ── Umfänge: chronologisch für Trends & Proportionen ──
+ const circChron = [...filtCir].sort((a,b)=>a.date.localeCompare(b.date))
const cirCd = [...filtCir].filter(c=>c.c_waist||c.c_hip).reverse().map(c=>({
date:fmtDate(c.date),waist:c.c_waist,hip:c.c_hip,belly:c.c_belly
}))
+ const propBase = circChron
+ .filter(r => r.c_chest && r.c_waist)
+ .map(r => ({
+ date: fmtDate(r.date),
+ vTaper: Math.round((r.c_chest - r.c_waist) * 10) / 10,
+ belly: r.c_belly != null ? Math.round(r.c_belly * 10) / 10 : null,
+ }))
+ const propChartData = propBase.length >= 2 ? rollingAvg(propBase, 'vTaper', 3) : []
+ const showBellyOnProp = propChartData.some(d => d.belly != null)
+
+ const fbFirst = { chest: null, waist: null, belly: null }
+ for (const r of circChron) {
+ if (fbFirst.chest == null && r.c_chest) fbFirst.chest = r.c_chest
+ if (fbFirst.waist == null && r.c_waist) fbFirst.waist = r.c_waist
+ if (fbFirst.belly == null && r.c_belly) fbFirst.belly = r.c_belly
+ }
+ const idxSeries = circChron.map(r => ({
+ date: fmtDate(r.date),
+ chestIdx: r.c_chest && fbFirst.chest ? Math.round((r.c_chest / fbFirst.chest) * 1000) / 10 : null,
+ waistIdx: r.c_waist && fbFirst.waist ? Math.round((r.c_waist / fbFirst.waist) * 1000) / 10 : null,
+ bellyIdx: r.c_belly && fbFirst.belly ? Math.round((r.c_belly / fbFirst.belly) * 1000) / 10 : null,
+ }))
+ const idxCount = idxSeries.filter(row => row.chestIdx != null || row.waistIdx != null || row.bellyIdx != null).length
+ const idxOk = idxCount >= 2 && (fbFirst.chest || fbFirst.waist || fbFirst.belly)
+
// ── Indicators ──
const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
@@ -223,12 +345,20 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
weight:latestW2?.weight
}
const rules = getInterpretation(combined, profile, prevCal||null)
+ const derivedFFMI = calcDerived(combined, height)?.ffmi
return (
+
+
+
+ Hinweis: Diese Seite bündelt Körpermaße und -zusammensetzung. Trainingsbedingte Fitness (Belastung, Leistung, Ausdauer) findest du unter{' '}
+ Verlauf → Aktivität — dort werden sportliche Trends ausgewertet, hier geht es um Silhouette, Zusammensetzung und Gesundheitsindikatoren.
+
+
{/* Summary stats */}
{latestW2 &&
@@ -243,6 +373,10 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
{latestCal.lean_mass} kg
Mager
}
+ {derivedFFMI != null &&
}
{whr &&
{whr}
@@ -316,43 +450,119 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
)}
- {/* KF + Magermasse chart */}
+ {/* Körperfett — eine Zeitreihe (Magermasse steht oben als Kennzahl) */}
{bfCd.length>=2 && (
-
KF% + Magermasse
+
Körperfett (Caliper)
-
-
+
[`${v}${n==='bf'?'%':' kg'}`,n==='bf'?'KF%':'Mager']}/>
- {profile?.goal_bf_pct && [`${v}%`, 'KF%']}/>
+ {profile?.goal_bf_pct && }
-
-
+
-
-
KF%
- Mager kg
- {profile?.goal_bf_pct && Ziel KF}
+
+ Magermasse ergibt sich aus Gewicht und KF% — als zweite Kurve wäre sie redundant. Aktuelle Magermasse siehe Kennzahlen oben.
)}
- {/* Circ trend */}
- {cirCd.length>=2 && (
+ {/* Proportion: V-Taper vs. Bauch (Brust−Taille vs. Bauchumfang) */}
+ {propChartData.length >= 2 && (
+
+
+
+
Silhouette & Proportion
+
+ V-Taper (Brust − Taille) in cm: größer bedeutet stärkere Schulter-/Brustentwicklung relativ zur Taille.
+ {showBellyOnProp && (
+ <> Bauch (rechte Achse): steigender Trend hier deutet eher auf Zunahme zentralen Umfangs hin — unabhängig von sportlicher Brustentwicklung.>
+ )}
+
+
+
+
+
+
+
+
+
+ {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)}
+
+
+ )}
+
+ {/* Relative Umfangsänderung (Index erste Messung im Zeitraum = 100) */}
+ {idxOk && (
-
Umfänge Verlauf
+
+
Relative Entwicklung der Umfänge
+
+ Index 100 = erste erfasste Messung im Zeitraum. So sind Trend und Richtung besser vergleichbar als absolute cm-Werte nebeneinander.
+
+
+
+
+
+
+
+
+ [`${v} Index`, n==='chestIdx'?'Brust':n==='waistIdx'?'Taille':'Bauch']}/>
+ {idxSeries.some(d=>d.chestIdx!=null) && }
+ {idxSeries.some(d=>d.waistIdx!=null) && }
+ {idxSeries.some(d=>d.bellyIdx!=null) && }
+
+
+
+ Brust
+ Taille
+ Bauch
+
+
+ )}
+
+ {/* Fallback: klassischer Taille/Hüfte/Bauch-Verlauf wenn keine Brust-Taille-Kombi */}
+ {propChartData.length < 2 && cirCd.length>=2 && (
+
+
+
Umfänge (Taille / Hüfte / Bauch)
+
+
+
+ Sobald Brust- und Taillenumfang gemeinsam erfasst sind, erscheint oben die Proportionen-Ansicht (V-Taper).
+
@@ -368,36 +578,7 @@ function BodySection({ weights, calipers, circs, profile, insights, onRequest, l
)}
- {/* WHR / WHtR detail */}
- {(whr||whtr) && (
-
- {whr &&
-
{whr}
-
WHR
-
Taille ÷ Hüfte
-
Ziel <{sex==='m'?'0,90':'0,85'}
-
- {whr<(sex==='m'?0.90:0.85)?'✓ Günstig':'⚠️ Erhöht'}
-
}
- {whtr &&
-
{whtr}
-
WHtR
-
Taille ÷ Körpergröße
-
Ziel <0,50
-
- {whtr<0.5?'✓ Optimal':'⚠️ Erhöht'}
-
}
-
- )}
-
- {rules.length>0 && (
-
-
BEWERTUNG
- {rules.map((item,i)=>
)}
-
- )}
+