refactor: update KPI overview and evaluation tile components
- 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:
parent
461c358dc2
commit
b2175b9018
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user