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 &&
+
{derivedFFMI}
+
FFMI
+
} {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)=>)} -
- )} +