mitai-jinkendo/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx
Lars ddc87ba5ae
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: remove deprecated demo route and enhance dashboard widget registration
- Removed outdated visualization demo route and fixed demo layout in the frontend.
- Updated widget registration logic in `frontend/src/widgetSystem/registerDashboardWidgets.js` to ensure proper integration of core widgets.
- Adjusted documentation in `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` and comments in `backend/widget_catalog.py` to reflect changes.
- Added new dashboard widgets for activity and body overview, enhancing user experience and data visualization capabilities.
- Bumped application version to reflect these changes.
2026-04-23 15:24:13 +02:00

231 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}