2026-04-07 12:46:18 +02:00
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
2026-04-07 11:07:33 +02:00
|
|
|
|
import { Link } from 'react-router-dom'
|
|
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import { api } from '../../utils/api'
|
|
|
|
|
|
import { getBfCategory } from '../../utils/calc'
|
|
|
|
|
|
import { useProfile } from '../../context/ProfileContext'
|
2026-04-07 12:37:04 +02:00
|
|
|
|
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
2026-04-07 12:46:18 +02:00
|
|
|
|
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
|
2026-04-07 11:07:33 +02:00
|
|
|
|
|
|
|
|
|
|
const MAX_KPI = 9
|
|
|
|
|
|
|
|
|
|
|
|
function formatRefVal(row) {
|
|
|
|
|
|
if (row.value_numeric != null && row.value_numeric !== '') {
|
|
|
|
|
|
const n = Number(row.value_numeric)
|
|
|
|
|
|
return Number.isFinite(n) ? String(n) : String(row.value_numeric)
|
|
|
|
|
|
}
|
|
|
|
|
|
return row.value_text != null ? String(row.value_text) : '–'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:46:18 +02:00
|
|
|
|
function parseRefTypeKey(tileId) {
|
|
|
|
|
|
if (!tileId.startsWith('ref:')) return null
|
|
|
|
|
|
return tileId.slice(4) || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildAutoTileIds(refTiles, hasBf, hasKcal) {
|
|
|
|
|
|
const ids = []
|
|
|
|
|
|
for (const t of refTiles) {
|
|
|
|
|
|
if (t?.type_key) ids.push(`ref:${t.type_key}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (hasBf) ids.push('body_fat')
|
|
|
|
|
|
if (hasKcal) ids.push('avg_kcal')
|
|
|
|
|
|
return ids.slice(0, MAX_KPI)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 11:07:33 +02:00
|
|
|
|
/**
|
2026-04-07 12:46:18 +02:00
|
|
|
|
* KPIs: Referenzwerte, Körperfett, Ø Kalorien — max. 9 Kacheln.
|
|
|
|
|
|
* @param {{ refreshTick?: number, kpiConfig?: Record<string, unknown> }} props
|
|
|
|
|
|
* kpiConfig.tiles: geordnete Kachel-ids; fehlend = automatische Belegung (wie bisher).
|
2026-04-07 11:07:33 +02:00
|
|
|
|
*/
|
2026-04-07 12:46:18 +02:00
|
|
|
|
export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
|
|
|
|
|
const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig])
|
|
|
|
|
|
|
2026-04-07 11:07:33 +02:00
|
|
|
|
const { activeProfile } = useProfile()
|
|
|
|
|
|
const sex = activeProfile?.sex || 'm'
|
2026-04-07 12:46:18 +02:00
|
|
|
|
const [refTiles, setRefTiles] = useState([])
|
|
|
|
|
|
const [refByKey, setRefByKey] = useState(() => new Map())
|
2026-04-07 11:07:33 +02:00
|
|
|
|
const [bf, setBf] = useState(null)
|
|
|
|
|
|
const [avgKcal, setAvgKcal] = useState(null)
|
|
|
|
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [err, setErr] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
;(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true)
|
2026-04-07 12:46:18 +02:00
|
|
|
|
const kcalDays = KPI_KCAL_WINDOW_DEFAULT
|
|
|
|
|
|
const nutrLimit = Math.min(2000, Math.max(60, kcalDays * 5))
|
2026-04-07 11:07:33 +02:00
|
|
|
|
const [summary, calipers, nutrition] = await Promise.all([
|
|
|
|
|
|
api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
|
|
|
|
|
|
api.listCaliper(3).catch(() => []),
|
2026-04-07 12:37:04 +02:00
|
|
|
|
api.listNutrition(nutrLimit).catch(() => []),
|
2026-04-07 11:07:33 +02:00
|
|
|
|
])
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
const tiles = Array.isArray(summary?.tiles) ? summary.tiles.filter((t) => t?.latest) : []
|
2026-04-07 12:46:18 +02:00
|
|
|
|
const map = new Map(tiles.map((t) => [t.type_key, t]))
|
2026-04-07 11:07:33 +02:00
|
|
|
|
const latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null
|
2026-04-07 12:37:04 +02:00
|
|
|
|
const recentNutr = (nutrition || []).filter(
|
2026-04-07 12:46:18 +02:00
|
|
|
|
(n) => n.date >= dayjs().subtract(kcalDays, 'day').format('YYYY-MM-DD'),
|
2026-04-07 12:39:41 +02:00
|
|
|
|
)
|
2026-04-07 11:07:33 +02:00
|
|
|
|
const kcal =
|
|
|
|
|
|
recentNutr.length > 0
|
|
|
|
|
|
? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length)
|
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
|
|
const wantBf = !!latestCal?.body_fat_pct
|
|
|
|
|
|
const wantKcal = kcal != null && kcal > 0
|
2026-04-07 12:46:18 +02:00
|
|
|
|
|
|
|
|
|
|
setRefTiles(tiles)
|
|
|
|
|
|
setRefByKey(map)
|
2026-04-07 11:07:33 +02:00
|
|
|
|
setBf(
|
|
|
|
|
|
wantBf
|
|
|
|
|
|
? {
|
|
|
|
|
|
pct: latestCal.body_fat_pct,
|
|
|
|
|
|
cat: getBfCategory(latestCal.body_fat_pct, sex),
|
|
|
|
|
|
date: latestCal.date,
|
|
|
|
|
|
}
|
|
|
|
|
|
: null,
|
|
|
|
|
|
)
|
|
|
|
|
|
setAvgKcal(wantKcal ? kcal : null)
|
|
|
|
|
|
setErr(null)
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
|
setErr(e.message || 'KPIs konnten nicht geladen werden')
|
2026-04-07 12:46:18 +02:00
|
|
|
|
setRefTiles([])
|
|
|
|
|
|
setRefByKey(new Map())
|
2026-04-07 11:07:33 +02:00
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
})()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
2026-04-07 12:46:18 +02:00
|
|
|
|
}, [refreshTick, sex])
|
|
|
|
|
|
|
|
|
|
|
|
const orderIds = useMemo(() => {
|
|
|
|
|
|
if (manualOrder !== undefined) {
|
|
|
|
|
|
return manualOrder
|
|
|
|
|
|
}
|
|
|
|
|
|
const hasBf = !!bf
|
|
|
|
|
|
const hasKcal = avgKcal != null && avgKcal > 0
|
|
|
|
|
|
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 out = []
|
|
|
|
|
|
for (const id of orderIds) {
|
|
|
|
|
|
pushTileForId(id, out)
|
|
|
|
|
|
}
|
|
|
|
|
|
return out
|
|
|
|
|
|
}, [orderIds, pushTileForId])
|
2026-04-07 11:07:33 +02:00
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
|
|
|
|
|
|
<div className="spinner" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap" style={{ color: 'var(--danger)', fontSize: 13 }}>
|
|
|
|
|
|
{err}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-07 12:46:18 +02:00
|
|
|
|
if (visibleTiles.length === 0) {
|
2026-04-07 11:07:33 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">Kennzahlen</div>
|
|
|
|
|
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
2026-04-07 12:46:18 +02:00
|
|
|
|
Noch keine Daten oder keine passenden Kacheln.{' '}
|
2026-04-07 11:07:33 +02:00
|
|
|
|
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
|
|
|
|
|
|
Referenzwerte
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
,{' '}
|
|
|
|
|
|
<Link to="/caliper" style={{ color: 'var(--accent)' }}>
|
|
|
|
|
|
Caliper
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
,{' '}
|
|
|
|
|
|
<Link to="/nutrition" style={{ color: 'var(--accent)' }}>
|
|
|
|
|
|
Ernährung
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">Kennzahlen</div>
|
|
|
|
|
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
2026-04-07 12:46:18 +02:00
|
|
|
|
{manualOrder !== undefined
|
|
|
|
|
|
? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).'
|
|
|
|
|
|
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
|
2026-04-07 11:07:33 +02:00
|
|
|
|
</p>
|
2026-04-07 12:46:18 +02:00
|
|
|
|
<div className="ref-value-tiles-grid">{visibleTiles}</div>
|
2026-04-07 11:07:33 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|