refactor: update KPI overview and evaluation tile components
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- Renamed and refactored CSS classes for better clarity and consistency in the KPI overview section.
- Introduced a new `BodyKpiOverview` component to display KPI tiles with detailed hover information.
- Enhanced the `buildBodyKpiTiles` function to generate tiles based on various body metrics, improving data presentation.
- Updated styles for the KPI cards to enhance user interaction and visual appeal.
- Removed the old `EvaluationTileGrid` component in favor of the new structure for better maintainability.
This commit is contained in:
Lars 2026-04-19 16:21:37 +02:00
parent 461c358dc2
commit b2175b9018
2 changed files with 196 additions and 131 deletions

View File

@ -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 {

View File

@ -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 (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>BEWERTUNG</div>
<div className="body-eval-grid">
{items.map((item, i) => {
const color = getStatusColor(item.status)
const expanded = open === i
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</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 (
<button
key={i}
type="button"
className="body-eval-tile"
style={{ borderTop: `3px solid ${color}` }}
onClick={() => setOpen(expanded ? null : i)}
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}` }}
title={tip}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 6, textAlign: 'left', width: '100%' }}>
<span style={{ fontSize: 16, lineHeight: 1 }}>{item.icon}</span>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 9, fontWeight: 600, color, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>{item.category}</div>
<div style={{ fontSize: 12, fontWeight: 600, lineHeight: 1.3, color: 'var(--text1)' }}>{item.title}</div>
{expanded && (
<>
<div style={{ fontSize: 11, color: 'var(--text2)', lineHeight: 1.45, marginTop: 6 }}>{item.detail}</div>
{item.related_placeholder_keys?.length > 0 && (
<div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 6, lineHeight: 1.35 }}>
Layer 2a (Registry): {item.related_placeholder_keys.join(', ')}
</div>
)}
</>
<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>
{item.value && (
<span style={{ fontSize: 13, fontWeight: 700, color, flexShrink: 0 }}>{item.value}</span>
)}
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
</div>
</button>
</div>
)
})}
</div>
@ -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
<BodyGoalsStrip grouped={groupedGoals} />
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
<strong>Layer 2b</strong>: Diagramme und Bewertung stammen aus dem Backend-Bundle dieselben Rohdaten und Kennzahlen wie die Körper-Platzhalter (Registry).{' '}
Sportliche Fitness: <strong>Verlauf Aktivität</strong>.
<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: 10, lineHeight: 1.4 }}>
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
{summary.weight_kg != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{summary.weight_kg} kg</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Aktuell</div>
</div>
)}
{summary.body_fat_pct != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: bfCat?.color }}>{summary.body_fat_pct}%</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>KF {bfCat?.label || summary.bf_category_label}</div>
</div>
)}
{summary.lean_mass_kg != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#1D9E75' }}>{summary.lean_mass_kg} kg</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Mager</div>
</div>
)}
{summary.ffmi != null && (
<div style={{ flex: 1, minWidth: 70, background: 'var(--surface2)', borderRadius: 8, padding: '8px 6px', textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 700, color: '#378ADD' }}>{summary.ffmi}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>FFMI</div>
</div>
)}
{whr != null && (
<div
style={{
flex: 1,
minWidth: 70,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 6px',
textAlign: 'center',
borderTop: `3px solid ${whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)'}`,
}}
>
<div style={{ fontSize: 16, fontWeight: 700, color: whr < (sex === 'm' ? 0.9 : 0.85) ? 'var(--accent)' : 'var(--warn)' }}>{whr}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHR</div>
</div>
)}
{whtr != null && (
<div
style={{
flex: 1,
minWidth: 70,
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 6px',
textAlign: 'center',
borderTop: `3px solid ${whtr < 0.5 ? 'var(--accent)' : 'var(--warn)'}`,
}}
>
<div style={{ fontSize: 16, fontWeight: 700, color: whtr < 0.5 ? 'var(--accent)' : 'var(--warn)' }}>{whtr}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>WHtR</div>
</div>
)}
</div>
<BodyKpiOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
@ -485,25 +574,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
<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>
{trendPeriods.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
{trendPeriods.map(({ label, diff_kg: diff }) => (
<div key={label} style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center', borderTop: `3px solid ${diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--border)'}` }}>
<div style={{ fontSize: 15, fontWeight: 700, color: diff < 0 ? 'var(--accent)' : diff > 0 ? 'var(--warn)' : 'var(--text3)' }}>
{diff > 0 ? '+' : ''}{diff} kg
</div>
<div style={{ fontSize: 10, color: 'var(--text3)' }}>{label}</div>
</div>
))}
{minW != null && (
<div style={{ flex: 1, background: 'var(--surface2)', borderRadius: 8, padding: '6px 8px', textAlign: 'center' }}>
<div style={{ fontSize: 12, color: 'var(--accent)', fontWeight: 600 }}>{minW}</div>
<div style={{ fontSize: 12, color: 'var(--warn)', fontWeight: 600 }}>{maxW}</div>
<div style={{ fontSize: 9, color: 'var(--text3)' }}>Min/Max</div>
</div>
)}
</div>
)}
</div>
)}
@ -616,8 +686,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</div>
)}
<EvaluationTileGrid items={rules} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['pipeline', 'koerper', 'gesundheit', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)