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) ? ( +
+
{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}
- )} -
-
-
{t.verdict}
-
-
-
- ) - })} -
- - {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…