refactor: integrate KpiTilesOverview component for enhanced KPI display
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced the KpiTilesOverview component to streamline the presentation of KPI tiles, replacing the previous BodyKpiOverview implementation.
- Updated the PilotKpiBoard and History components to utilize the new KpiTilesOverview for better touch and hover interactions.
- Refactored CSS styles to accommodate the new component structure, ensuring a responsive design across devices.
- Enhanced the logic for generating KPI tiles, improving data handling and user experience.
This commit is contained in:
Lars 2026-04-19 17:00:05 +02:00
parent 8c60601ed1
commit 08b7aa0ca1
4 changed files with 248 additions and 176 deletions

View File

@ -199,13 +199,16 @@ 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: KPI-Übersicht (Hover = Details, kein Klick) */
/* KPI-Kachel-Raster: gemeinsam für Verlauf Körper, Dashboard KPI-Board,
Desktop: title-Tooltip; Touch: Bottom-Sheet (siehe KpiTilesOverview.jsx) */
.kpi-tiles-grid,
.body-kpi-overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.kpi-tiles-card,
.body-kpi-card {
background: var(--surface2);
border-radius: 10px;
@ -215,12 +218,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@media (hover: none) {
.kpi-tiles-card,
.body-kpi-card {
cursor: default;
}
}
.kpi-tiles-card:hover,
.body-kpi-card:hover {
border-color: var(--border2);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
}
/* Kennzahlen: Touch — iOS hat kein Hover; öffnet Bottom-Sheet */
.kpi-tiles-info-btn,
.body-kpi-info-btn {
position: absolute;
top: 6px;
@ -239,11 +249,13 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kpi-tiles-info-btn:active,
.body-kpi-info-btn:active {
background: var(--surface);
color: var(--accent);
}
.kpi-tiles-touch-backdrop,
.body-kpi-touch-backdrop {
position: fixed;
inset: 0;
@ -254,14 +266,19 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
padding: 0 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.45);
animation: body-kpi-fade-in 0.15s ease;
animation: kpi-tiles-fade-in 0.15s ease;
}
@keyframes kpi-tiles-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes body-kpi-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.kpi-tiles-touch-sheet,
.body-kpi-touch-sheet {
width: 100%;
max-width: 520px;
@ -276,6 +293,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18);
}
.kpi-tiles-touch-sheet__head,
.body-kpi-touch-sheet__head {
display: flex;
align-items: flex-start;
@ -284,6 +302,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
margin-bottom: 10px;
}
.kpi-tiles-touch-sheet__title,
.body-kpi-touch-sheet__title {
margin: 0;
font-size: 16px;
@ -294,6 +313,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
min-width: 0;
}
.kpi-tiles-touch-sheet__close,
.body-kpi-touch-sheet__close {
flex-shrink: 0;
width: 40px;
@ -310,10 +330,12 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
-webkit-tap-highlight-color: transparent;
}
.kpi-tiles-touch-sheet__close:active,
.body-kpi-touch-sheet__close:active {
background: var(--surface2);
}
.kpi-tiles-touch-sheet__body,
.body-kpi-touch-sheet__body {
font-size: 13px;
line-height: 1.5;
@ -322,6 +344,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
word-break: break-word;
}
.kpi-tiles-touch-sheet__body--muted,
.body-kpi-touch-sheet__body--muted {
color: var(--text3);
font-style: italic;

View File

@ -0,0 +1,156 @@
import { useState, useEffect, useId } from 'react'
import { Info } from 'lucide-react'
import { getStatusColor } from '../utils/interpret'
/**
* Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip.
* @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t
*/
export function kpiTileDetailParts(t) {
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
return { title: t.hoverTop || t.category || 'Kennzahl', body }
}
/** Ein Zeilentext wie natives `title` (Desktop-Hover). */
export function buildKpiTileTitleString(t) {
return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '']
.filter(Boolean)
.join('\n\n')
}
/**
* Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch Bottom-Sheet (gleicher Inhalt).
*
* Erwartete Kachel-Felder:
* - `key` (string, eindeutig)
* - `category` (string) Zeilenkopf
* - `value` (ReactNode) Hauptwert
* - `status` für Farbstreifen: `good` | `warn` | `bad`
* - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys`
*/
export default function KpiTilesOverview({
tiles,
heading = 'Kennzahlen',
showTouchHint = true,
gridClassName = 'kpi-tiles-grid',
marginBottom = 12,
}) {
const [touchUi, setTouchUi] = useState(false)
const [openKey, setOpenKey] = useState(null)
const sheetTitleId = useId()
useEffect(() => {
const mq = window.matchMedia('(hover: none)')
const apply = () => setTouchUi(mq.matches)
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [])
useEffect(() => {
if (!openKey) return
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [openKey])
if (!tiles?.length) return null
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
const openParts = openTile ? kpiTileDetailParts(openTile) : null
const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—'
return (
<div style={{ marginBottom }}>
{heading ? (
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{heading}</div>
) : null}
{showTouchHint && touchUi && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
Auf dem Smartphone: <strong></strong> für Erklärung und Details.
</div>
)}
<div className={gridClassName}>
{tiles.map(t => {
const accent = getStatusColor(t.status)
const tip = buildKpiTileTitleString(t)
return (
<div
key={t.key}
className="kpi-tiles-card"
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
title={touchUi ? undefined : tip}
>
{touchUi && (
<button
type="button"
className="kpi-tiles-info-btn"
aria-label={`Details: ${t.category || t.hoverTop || 'Kennzahl'}`}
aria-expanded={openKey === t.key}
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
>
<Info size={16} strokeWidth={2.25} aria-hidden />
</button>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
{t.icon != null && t.icon !== false ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
) : (
<span style={{ width: 0, flexShrink: 0 }} aria-hidden />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<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>
) : null}
</div>
{showVerdict(t.verdict) ? (
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
) : null}
</div>
</div>
)
})}
</div>
{openParts && (
<div
className="kpi-tiles-touch-backdrop"
role="presentation"
onClick={() => setOpenKey(null)}
>
<div
className="kpi-tiles-touch-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={sheetTitleId}
onClick={e => e.stopPropagation()}
>
<div className="kpi-tiles-touch-sheet__head">
<h3 id={sheetTitleId} className="kpi-tiles-touch-sheet__title">{openParts.title}</h3>
<button type="button" className="kpi-tiles-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
×
</button>
</div>
{openParts.body ? (
<div className="kpi-tiles-touch-sheet__body">{openParts.body}</div>
) : (
<div className="kpi-tiles-touch-sheet__body kpi-tiles-touch-sheet__body--muted">Keine weiteren Details.</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import dayjs from 'dayjs'
import { api } from '../../utils/api'
@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc'
import { useProfile } from '../../context/ProfileContext'
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
import KpiTilesOverview from '../KpiTilesOverview'
const MAX_KPI = 9
@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
return buildAutoTileIds(refTiles, hasBf, hasKcal)
}, [manualOrder, refTiles, bf, avgKcal])
const pushTileForId = useCallback(
(id, out) => {
if (id === 'body_fat') {
if (!bf) return
out.push(
<div key="kpi-bf" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Körperfett</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: bf.cat?.color || 'var(--text1)' }}>
{bf.pct}%
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>{bf.cat?.label || 'Caliper'}</div>
</div>,
)
return
}
if (id === 'avg_kcal') {
if (avgKcal == null) return
out.push(
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>
Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T)
</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
</div>,
)
return
}
const tk = parseRefTypeKey(id)
if (!tk) return
const tile = refByKey.get(tk)
if (!tile?.latest) return
const l = tile.latest
out.push(
<div key={`ref-${tk}`} className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>{tile.type_label}</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4 }}>
{formatRefVal(l)}
{l.unit ? (
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>{l.unit}</span>
) : null}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ref.wert</div>
</div>,
)
},
[bf, avgKcal, refByKey],
)
const visibleTiles = useMemo(() => {
const kpiTiles = useMemo(() => {
const out = []
for (const id of orderIds) {
pushTileForId(id, out)
if (id === 'body_fat') {
if (!bf) continue
out.push({
key: 'kpi-bf',
status: 'good',
category: 'Körperfett',
icon: '🫧',
value: `${bf.pct}%`,
sublabel: bf.cat?.label || 'Caliper',
valueColor: bf.cat?.color,
hoverTop: 'Körperfett (Caliper)',
hoverBody:
`Letzte Messung: ${bf.date ? dayjs(bf.date).format('DD.MM.YYYY') : '—'}.\n` +
'Wert aus dem Caliper-Log; die Farbe/Kategorie richtet sich nach Geschlecht und üblicher Spanne.',
})
continue
}
if (id === 'avg_kcal') {
if (avgKcal == null) continue
out.push({
key: 'kpi-kcal',
status: 'good',
category: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T)`,
icon: '🍽️',
value: `${avgKcal} kcal`,
sublabel: 'Ernährung',
valueColor: '#EF9F27',
hoverTop: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)`,
hoverBody:
`Durchschnitt der täglichen Kalorien aus dem Ernährungs-Log über die letzten ${KPI_KCAL_WINDOW_DEFAULT} Tage (Mittel über alle geladenen Tageseinträge im Fenster).`,
})
continue
}
const tk = parseRefTypeKey(id)
if (!tk) continue
const tile = refByKey.get(tk)
if (!tile?.latest) continue
const l = tile.latest
const valStr = formatRefVal(l)
const withUnit = l.unit ? `${valStr} ${l.unit}`.trim() : valStr
out.push({
key: `ref-${tk}`,
status: 'good',
category: tile.type_label,
icon: '📌',
value: withUnit,
sublabel: 'Ref.wert',
hoverTop: tile.type_label,
hoverBody:
'Persönlicher Referenzwert aus dem Profil. Verwaltung unter Einstellungen → Referenzwerte.',
})
}
return out
}, [orderIds, pushTileForId])
}, [orderIds, bf, avgKcal, refByKey])
if (loading) {
return (
@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
)
}
if (visibleTiles.length === 0) {
if (kpiTiles.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Kennzahlen</div>
@ -216,7 +218,13 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).'
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
</p>
<div className="ref-value-tiles-grid">{visibleTiles}</div>
<KpiTilesOverview
tiles={kpiTiles}
heading={null}
showTouchHint
gridClassName="ref-value-tiles-grid"
marginBottom={0}
/>
</div>
)
}

View File

@ -6,7 +6,7 @@ import {
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, PieChart, Pie, Cell, ComposedChart
} from 'recharts'
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2, Info } from 'lucide-react'
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'
@ -15,6 +15,7 @@ import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -232,122 +233,6 @@ function buildBodyKpiTiles({
return tiles
}
function kpiTileDetailParts(t) {
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
return { title: t.hoverTop || t.category, body }
}
/** KPI-Kacheln: Desktop — Hover (`title`). Touch — öffnet gleichen Text im Bottom-Sheet (iOS hat kein Hover). */
function BodyKpiOverview({ tiles }) {
const [touchUi, setTouchUi] = useState(false)
const [openKey, setOpenKey] = useState(null)
useEffect(() => {
const mq = window.matchMedia('(hover: none)')
const apply = () => setTouchUi(mq.matches)
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [])
useEffect(() => {
if (!openKey) return
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [openKey])
if (!tiles?.length) return null
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
const openParts = openTile ? kpiTileDetailParts(openTile) : null
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
{touchUi && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
Auf dem Smartphone: <strong></strong> für Erklärung und Details.
</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 (
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
title={touchUi ? undefined : tip}
>
{touchUi && (
<button
type="button"
className="body-kpi-info-btn"
aria-label={`Details: ${t.category}`}
aria-expanded={openKey === t.key}
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
>
<Info size={16} strokeWidth={2.25} aria-hidden />
</button>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<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>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
</div>
</div>
)
})}
</div>
{openParts && (
<div
className="body-kpi-touch-backdrop"
role="presentation"
onClick={() => setOpenKey(null)}
>
<div
className="body-kpi-touch-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="body-kpi-touch-title"
onClick={e => e.stopPropagation()}
>
<div className="body-kpi-touch-sheet__head">
<h3 id="body-kpi-touch-title" className="body-kpi-touch-sheet__title">{openParts.title}</h3>
<button type="button" className="body-kpi-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
×
</button>
</div>
{openParts.body ? (
<div className="body-kpi-touch-sheet__body">{openParts.body}</div>
) : (
<div className="body-kpi-touch-sheet__body body-kpi-touch-sheet__body--muted">Keine weiteren Details.</div>
)}
</div>
</div>
)}
</div>
)
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter(g => g.status === 'active').slice(0, 4)
@ -613,7 +498,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</div>
)}
<BodyKpiOverview tiles={kpiTiles} />
<KpiTilesOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>