mitai-jinkendo/frontend/src/components/pilot/PilotKpiBoard.jsx

173 lines
6.0 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
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'
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
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) : '–'
}
/**
* KPIs: Referenzwerte (Layer-1-Summary) + Körperfett % + Ø Kalorien (Fenster konfigurierbar) — max. 9 Kacheln.
*/
export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KCAL_WINDOW_DEFAULT }) {
const { activeProfile } = useProfile()
const sex = activeProfile?.sex || 'm'
const [refs, setRefs] = useState([])
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)
const nutrLimit = Math.min(2000, Math.max(60, kcalWindowDays * 5))
const [summary, calipers, nutrition] = await Promise.all([
api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
api.listCaliper(3).catch(() => []),
api.listNutrition(nutrLimit).catch(() => []),
])
if (cancelled) return
const tiles = Array.isArray(summary?.tiles) ? summary.tiles.filter((t) => t?.latest) : []
const latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null
const recentNutr = (nutrition || []).filter(
(n) => n.date >= dayjs().subtract(kcalWindowDays, 'day').format('YYYY-MM-DD'),
})
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
const extra = (wantBf ? 1 : 0) + (wantKcal ? 1 : 0)
const refCap = Math.max(0, MAX_KPI - extra)
setRefs(tiles.slice(0, refCap))
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')
setRefs([])
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [refreshTick, sex, kcalWindowDays])
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>
)
}
const tiles = []
refs.forEach((tile) => {
const l = tile.latest
tiles.push(
<div key={`ref-${tile.type_key}`} 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>,
)
})
if (bf) {
tiles.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>,
)
}
if (avgKcal != null) {
tiles.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 ({kcalWindowDays}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>,
)
}
if (tiles.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Kennzahlen</div>
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
Noch keine Daten.{' '}
<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 }}>
Referenzwerte (bis {MAX_KPI} gesamt inkl. KF% und Ø-Kalorien). Fehlende Typen werden automatisch weggelassen.
</p>
<div className="ref-value-tiles-grid">{tiles}</div>
</div>
)
}