diff --git a/frontend/src/app.css b/frontend/src/app.css
index 73c1f3d..8a2c520 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -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;
diff --git a/frontend/src/components/KpiTilesOverview.jsx b/frontend/src/components/KpiTilesOverview.jsx
new file mode 100644
index 0000000..96150f9
--- /dev/null
+++ b/frontend/src/components/KpiTilesOverview.jsx
@@ -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 (
+
+ {heading ? (
+
{heading}
+ ) : null}
+ {showTouchHint && touchUi && (
+
+
+ Auf dem Smartphone: ℹ für Erklärung und Details.
+
+ )}
+
+ {tiles.map(t => {
+ const accent = getStatusColor(t.status)
+ const tip = buildKpiTileTitleString(t)
+ return (
+
+ {touchUi && (
+
+ )}
+
+ {t.icon != null && t.icon !== false ? (
+
{t.icon}
+ ) : (
+
+ )}
+
+
{t.category}
+
{t.value}
+ {t.sublabel ? (
+
{t.sublabel}
+ ) : null}
+
+ {showVerdict(t.verdict) ? (
+
+ ) : null}
+
+
+ )
+ })}
+
+
+ {openParts && (
+
setOpenKey(null)}
+ >
+
e.stopPropagation()}
+ >
+
+
{openParts.title}
+
+
+ {openParts.body ? (
+
{openParts.body}
+ ) : (
+
Keine weiteren Details.
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx
index 02b3898..b98e60c 100644
--- a/frontend/src/components/pilot/PilotKpiBoard.jsx
+++ b/frontend/src/components/pilot/PilotKpiBoard.jsx
@@ -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(
-
-
Körperfett
-
- {bf.pct}%
-
-
{bf.cat?.label || 'Caliper'}
-
,
- )
- return
- }
- if (id === 'avg_kcal') {
- if (avgKcal == null) return
- out.push(
-
-
- Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T)
-
-
{avgKcal} kcal
-
Ernährung
-
,
- )
- return
- }
- const tk = parseRefTypeKey(id)
- if (!tk) return
- const tile = refByKey.get(tk)
- if (!tile?.latest) return
- const l = tile.latest
- out.push(
-
-
{tile.type_label}
-
- {formatRefVal(l)}
- {l.unit ? (
- {l.unit}
- ) : null}
-
-
Ref.wert
-
,
- )
- },
- [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 (
Kennzahlen
@@ -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).`}
-
{visibleTiles}
+
)
}
diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx
index b1c0269..587a45f 100644
--- a/frontend/src/pages/History.jsx
+++ b/frontend/src/pages/History.jsx
@@ -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 (
-
-
Kennzahlen
- {touchUi && (
-
-
- Auf dem Smartphone: ℹ für Erklärung und Details.
-
- )}
-
- {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 (
-
- {touchUi && (
-
- )}
-
-
{t.icon}
-
-
{t.category}
-
{t.value}
- {t.sublabel && (
-
{t.sublabel}
- )}
-
-
-
-
- )
- })}
-
-
- {openParts && (
-
setOpenKey(null)}
- >
-
e.stopPropagation()}
- >
-
-
{openParts.title}
-
-
- {openParts.body ? (
-
{openParts.body}
- ) : (
-
Keine weiteren Details.
- )}
-
-
- )}
-
- )
-}
-
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
)}
-
+
{vizLoading && (
Aktualisiere…