mitai-jinkendo/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx

231 lines
7.6 KiB
React
Raw Normal View History

import { useState, useEffect, useMemo } 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'
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
import KpiTilesOverview from '../KpiTilesOverview'
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) : '–'
}
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)
}
/**
* 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).
*/
export default function KpiBoardWidget({ refreshTick = 0, kpiConfig }) {
const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig])
const { activeProfile } = useProfile()
const sex = activeProfile?.sex || 'm'
const [refTiles, setRefTiles] = useState([])
const [refByKey, setRefByKey] = useState(() => new Map())
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 kcalDays = KPI_KCAL_WINDOW_DEFAULT
const nutrLimit = Math.min(2000, Math.max(60, kcalDays * 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 map = new Map(tiles.map((t) => [t.type_key, t]))
const latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null
const recentNutr = (nutrition || []).filter(
(n) => n.date >= dayjs().subtract(kcalDays, '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
setRefTiles(tiles)
setRefByKey(map)
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')
setRefTiles([])
setRefByKey(new Map())
}
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [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 kpiTiles = useMemo(() => {
const out = []
for (const id of orderIds) {
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, bf, avgKcal, refByKey])
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>
)
}
if (kpiTiles.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 oder keine passenden Kacheln.{' '}
<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 }}>
{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).`}
</p>
<KpiTilesOverview
tiles={kpiTiles}
heading={null}
showTouchHint
gridClassName="ref-value-tiles-grid"
marginBottom={0}
/>
</div>
)
}