diff --git a/frontend/src/app.css b/frontend/src/app.css index fa1b72a..af03b56 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -199,28 +199,25 @@ 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 { +/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */ +.body-kpi-overview { display: grid; - grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 8px; + margin-bottom: 12px; } -.body-eval-tile { - display: block; - width: 100%; +.body-kpi-card { background: var(--surface2); border-radius: 10px; - padding: 8px 10px; - cursor: pointer; + padding: 10px 10px 10px 12px; border: 1px solid var(--border); - font: inherit; - color: inherit; + cursor: help; text-align: left; transition: border-color 0.15s ease, box-shadow 0.15s ease; } -.body-eval-tile:hover { +.body-kpi-card:hover { border-color: var(--border2); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07); } .history-page__title { diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index bdf4820..799a5d3 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -85,48 +85,184 @@ function RuleCard({ item }) { ) } -/** Kompakte Bewertungs-Kacheln (z. B. Körper-Verlauf) */ -function EvaluationTileGrid({ items }) { - const [open, setOpen] = useState(null) - if (!items?.length) return null +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: 'WHR', + icon: '📐', + value: String(summary.whr), + sublabel: '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: 'WHtR', + icon: '📏', + value: String(summary.whtr), + sublabel: '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 +} + +/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */ +function BodyKpiOverview({ tiles }) { + if (!tiles?.length) return null return (
-
BEWERTUNG
-
- {items.map((item, i) => { - const color = getStatusColor(item.status) - const expanded = open === i +
Kennzahlen
+
+ {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 ( - +
) })}
@@ -324,8 +460,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl belly: r.belly, })) - const whr = summary.whr - const whtr = summary.whtr const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct @@ -340,6 +474,19 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl 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) || @@ -378,75 +525,17 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl -

- Layer 2b: Diagramme und Bewertung stammen aus dem Backend-Bundle — dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '} - Sportliche Fitness: Verlauf → Aktivität. +

+ 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}
)} -
- {summary.weight_kg != null && ( -
-
{summary.weight_kg} kg
-
Aktuell
-
- )} - {summary.body_fat_pct != null && ( -
-
{summary.body_fat_pct}%
-
KF {bfCat?.label || summary.bf_category_label}
-
- )} - {summary.lean_mass_kg != null && ( -
-
{summary.lean_mass_kg} kg
-
Mager
-
- )} - {summary.ffmi != null && ( -
-
{summary.ffmi}
-
FFMI
-
- )} - {whr != null && ( -
-
{whr}
-
WHR
-
- )} - {whtr != null && ( -
-
{whtr}
-
WHtR
-
- )} -
+ {vizLoading && (
Aktualisiere…
@@ -485,25 +574,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl Ø 14T Ø Gesamt
- {trendPeriods.length > 0 && ( -
- {trendPeriods.map(({ label, diff_kg: diff }) => ( -
0 ? 'var(--warn)' : 'var(--border)'}` }}> -
0 ? 'var(--warn)' : 'var(--text3)' }}> - {diff > 0 ? '+' : ''}{diff} kg -
-
{label}
-
- ))} - {minW != null && ( -
-
{minW}
-
{maxW}
-
Min/Max
-
- )} -
- )}
)} @@ -616,8 +686,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl )} - - )