2026-04-19 17:00:05 +02:00
import { useState , useEffect , useMemo } 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-19 17:00:05 +02:00
import KpiTilesOverview from '../KpiTilesOverview'
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-23 15:24:13 +02:00
export default function KpiBoardWidget ( { refreshTick = 0 , kpiConfig } ) {
2026-04-07 12:46:18 +02:00
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 ] )
2026-04-19 17:00:05 +02:00
const kpiTiles = useMemo ( ( ) => {
const out = [ ]
for ( const id of orderIds ) {
2026-04-07 12:46:18 +02:00
if ( id === 'body_fat' ) {
2026-04-19 17:00:05 +02:00
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
2026-04-07 12:46:18 +02:00
}
if ( id === 'avg_kcal' ) {
2026-04-19 17:00:05 +02:00
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
2026-04-07 12:46:18 +02:00
}
const tk = parseRefTypeKey ( id )
2026-04-19 17:00:05 +02:00
if ( ! tk ) continue
2026-04-07 12:46:18 +02:00
const tile = refByKey . get ( tk )
2026-04-19 17:00:05 +02:00
if ( ! tile ? . latest ) continue
2026-04-07 12:46:18 +02:00
const l = tile . latest
2026-04-19 17:00:05 +02:00
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.' ,
} )
2026-04-07 12:46:18 +02:00
}
return out
2026-04-19 17:00:05 +02:00
} , [ orderIds , bf , avgKcal , refByKey ] )
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-19 17:00:05 +02:00
if ( kpiTiles . 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-19 17:00:05 +02:00
< KpiTilesOverview
tiles = { kpiTiles }
heading = { null }
showTouchHint
gridClassName = "ref-value-tiles-grid"
marginBottom = { 0 }
/ >
2026-04-07 11:07:33 +02:00
< / div >
)
}