feat: Revamp KPI board configuration and validation
- Enhanced the KPI board widget to support tile configuration, allowing users to select and order tiles. - Updated validation logic to ensure proper handling of tile IDs and configuration fields. - Removed legacy chart_days support, transitioning to a fixed analysis window for KPI metrics. - Improved the DashboardLabPage to integrate the new KpiBoardConfigEditor for managing tile selections. - Bumped app_dashboard version to 1.5.0 to reflect these significant changes.
This commit is contained in:
parent
096d896166
commit
c0c512e942
|
|
@ -7,12 +7,16 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
||||||
|
|
||||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview", "kpi_board"})
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview", "kpi_board"})
|
||||||
|
|
||||||
|
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||||
|
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
||||||
|
|
||||||
|
|
||||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
@ -36,11 +40,59 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
if widget_id == "activity_overview":
|
if widget_id == "activity_overview":
|
||||||
return _validate_chart_days_only(raw, label="activity_overview")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
return _validate_chart_days_only(raw, label="kpi_board")
|
return _validate_kpi_board_config(raw)
|
||||||
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
|
|
||||||
|
def _kpi_tile_id_valid(tid: str) -> bool:
|
||||||
|
if tid in _KPI_TILE_FIXED:
|
||||||
|
return True
|
||||||
|
return bool(_KPI_REF_TILE_RE.match(tid))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_kpi_tile_entry(item: Any) -> str:
|
||||||
|
if isinstance(item, str):
|
||||||
|
tid = item.strip()
|
||||||
|
elif isinstance(item, dict) and "id" in item:
|
||||||
|
tid = str(item["id"]).strip()
|
||||||
|
else:
|
||||||
|
raise ValueError("kpi_board: jedes tiles-Element braucht eine id (String oder Objekt mit id)")
|
||||||
|
if not tid:
|
||||||
|
raise ValueError("kpi_board: leere Kachel-id")
|
||||||
|
if not _kpi_tile_id_valid(tid):
|
||||||
|
raise ValueError(f"kpi_board: ungültige Kachel-id {tid!r} (z. B. body_fat, avg_kcal, ref:typ_key)")
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_kpi_board_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
# Legacy nur chart_days → entfallen, automatische Kachelwahl
|
||||||
|
if set(raw.keys()) <= frozenset({"chart_days"}):
|
||||||
|
return {}
|
||||||
|
allowed = frozenset({"tiles"})
|
||||||
|
unknown = set(raw) - allowed
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"kpi_board: unbekannte config-Felder: {sorted(unknown)}")
|
||||||
|
tiles_raw = raw.get("tiles")
|
||||||
|
if tiles_raw is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(tiles_raw, list):
|
||||||
|
raise ValueError("kpi_board: tiles muss eine Liste sein")
|
||||||
|
if len(tiles_raw) > 9:
|
||||||
|
raise ValueError("kpi_board: maximal 9 Kacheln")
|
||||||
|
seen: set[str] = set()
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
for item in tiles_raw:
|
||||||
|
tid = _normalize_kpi_tile_entry(item)
|
||||||
|
if tid in seen:
|
||||||
|
raise ValueError(f"kpi_board: doppelte Kachel-id {tid}")
|
||||||
|
seen.add(tid)
|
||||||
|
out.append({"id": tid})
|
||||||
|
return {"tiles": out}
|
||||||
|
|
||||||
|
|
||||||
def _parse_chart_days(v: Any, label: str) -> int:
|
def _parse_chart_days(v: Any, label: str) -> int:
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,25 @@ def test_activity_chart_days():
|
||||||
validate_widget_entry_config("activity_overview", {"chart_days": 5})
|
validate_widget_entry_config("activity_overview", {"chart_days": 5})
|
||||||
|
|
||||||
|
|
||||||
def test_kpi_board_chart_days():
|
def test_kpi_board_tiles():
|
||||||
assert validate_widget_entry_config("kpi_board", {}) == {}
|
assert validate_widget_entry_config("kpi_board", {}) == {}
|
||||||
assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {"chart_days": 14}
|
assert validate_widget_entry_config("kpi_board", {"tiles": []}) == {"tiles": []}
|
||||||
|
assert validate_widget_entry_config(
|
||||||
|
"kpi_board",
|
||||||
|
{"tiles": [{"id": "body_fat"}, {"id": "avg_kcal"}, {"id": "ref:hr_max"}]},
|
||||||
|
) == {"tiles": [{"id": "body_fat"}, {"id": "avg_kcal"}, {"id": "ref:hr_max"}]}
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("kpi_board", {"chart_days": 5})
|
validate_widget_entry_config("kpi_board", {"tiles": [{"id": "unknown"}]})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_widget_entry_config("kpi_board", {"tiles": [{"id": "body_fat"}, {"id": "body_fat"}]})
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_widget_entry_config("kpi_board", {"extra": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_kpi_board_legacy_chart_days_dropped():
|
||||||
|
"""Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr."""
|
||||||
|
assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {}
|
||||||
|
assert validate_widget_entry_config("kpi_board", {"chart_days": 5}) == {}
|
||||||
|
|
||||||
|
|
||||||
def test_welcome_still_rejects_config():
|
def test_welcome_still_rejects_config():
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||||
"app_dashboard": "1.4.0", # kpi_board.config.chart_days (Ø-Kalorien Fenster)
|
"app_dashboard": "1.5.0", # kpi_board: Kachelwahl tiles statt chart_days
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
{
|
{
|
||||||
"id": "kpi_board",
|
"id": "kpi_board",
|
||||||
"title": "KPI-Kacheln",
|
"title": "KPI-Kacheln",
|
||||||
"description": "Referenzwerte, KF%, Ø-Kalorien (optional: chart_days 7–90 für Ernährungsfenster)",
|
"description": "Referenzwerte, KF%, Ø-Kalorien — optional Kacheln & Reihenfolge (config.tiles, max. 9)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "body_overview",
|
"id": "body_overview",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { api } from '../../utils/api'
|
import { api } from '../../utils/api'
|
||||||
import { getBfCategory } from '../../utils/calc'
|
import { getBfCategory } from '../../utils/calc'
|
||||||
import { useProfile } from '../../context/ProfileContext'
|
import { useProfile } from '../../context/ProfileContext'
|
||||||
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
||||||
|
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
|
||||||
|
|
||||||
const MAX_KPI = 9
|
const MAX_KPI = 9
|
||||||
|
|
||||||
|
|
@ -16,13 +17,33 @@ function formatRefVal(row) {
|
||||||
return row.value_text != null ? String(row.value_text) : '–'
|
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 (Layer-1-Summary) + Körperfett % + Ø Kalorien (Fenster konfigurierbar) — max. 9 Kacheln.
|
* 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 PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KCAL_WINDOW_DEFAULT }) {
|
export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
||||||
|
const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig])
|
||||||
|
|
||||||
const { activeProfile } = useProfile()
|
const { activeProfile } = useProfile()
|
||||||
const sex = activeProfile?.sex || 'm'
|
const sex = activeProfile?.sex || 'm'
|
||||||
const [refs, setRefs] = useState([])
|
const [refTiles, setRefTiles] = useState([])
|
||||||
|
const [refByKey, setRefByKey] = useState(() => new Map())
|
||||||
const [bf, setBf] = useState(null)
|
const [bf, setBf] = useState(null)
|
||||||
const [avgKcal, setAvgKcal] = useState(null)
|
const [avgKcal, setAvgKcal] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -33,7 +54,8 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const nutrLimit = Math.min(2000, Math.max(60, kcalWindowDays * 5))
|
const kcalDays = KPI_KCAL_WINDOW_DEFAULT
|
||||||
|
const nutrLimit = Math.min(2000, Math.max(60, kcalDays * 5))
|
||||||
const [summary, calipers, nutrition] = await Promise.all([
|
const [summary, calipers, nutrition] = await Promise.all([
|
||||||
api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
|
api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
|
||||||
api.listCaliper(3).catch(() => []),
|
api.listCaliper(3).catch(() => []),
|
||||||
|
|
@ -41,9 +63,10 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const tiles = Array.isArray(summary?.tiles) ? summary.tiles.filter((t) => t?.latest) : []
|
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 latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null
|
||||||
const recentNutr = (nutrition || []).filter(
|
const recentNutr = (nutrition || []).filter(
|
||||||
(n) => n.date >= dayjs().subtract(kcalWindowDays, 'day').format('YYYY-MM-DD'),
|
(n) => n.date >= dayjs().subtract(kcalDays, 'day').format('YYYY-MM-DD'),
|
||||||
)
|
)
|
||||||
const kcal =
|
const kcal =
|
||||||
recentNutr.length > 0
|
recentNutr.length > 0
|
||||||
|
|
@ -52,9 +75,9 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
|
|
||||||
const wantBf = !!latestCal?.body_fat_pct
|
const wantBf = !!latestCal?.body_fat_pct
|
||||||
const wantKcal = kcal != null && kcal > 0
|
const wantKcal = kcal != null && kcal > 0
|
||||||
const extra = (wantBf ? 1 : 0) + (wantKcal ? 1 : 0)
|
|
||||||
const refCap = Math.max(0, MAX_KPI - extra)
|
setRefTiles(tiles)
|
||||||
setRefs(tiles.slice(0, refCap))
|
setRefByKey(map)
|
||||||
setBf(
|
setBf(
|
||||||
wantBf
|
wantBf
|
||||||
? {
|
? {
|
||||||
|
|
@ -69,7 +92,8 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setErr(e.message || 'KPIs konnten nicht geladen werden')
|
setErr(e.message || 'KPIs konnten nicht geladen werden')
|
||||||
setRefs([])
|
setRefTiles([])
|
||||||
|
setRefByKey(new Map())
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false)
|
if (!cancelled) setLoading(false)
|
||||||
|
|
@ -78,7 +102,73 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [refreshTick, sex, kcalWindowDays])
|
}, [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])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -95,54 +185,12 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tiles = []
|
if (visibleTiles.length === 0) {
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Kennzahlen</div>
|
<div className="card-title">Kennzahlen</div>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
Noch keine Daten.{' '}
|
Noch keine Daten oder keine passenden Kacheln.{' '}
|
||||||
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
|
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
|
||||||
Referenzwerte
|
Referenzwerte
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -164,9 +212,11 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Kennzahlen</div>
|
<div className="card-title">Kennzahlen</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
<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.
|
{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>
|
</p>
|
||||||
<div className="ref-value-tiles-grid">{tiles}</div>
|
<div className="ref-value-tiles-grid">{visibleTiles}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@ import {
|
||||||
BODY_CHART_DAYS_DEFAULT,
|
BODY_CHART_DAYS_DEFAULT,
|
||||||
BODY_CHART_DAYS_MAX,
|
BODY_CHART_DAYS_MAX,
|
||||||
BODY_CHART_DAYS_MIN,
|
BODY_CHART_DAYS_MIN,
|
||||||
KPI_KCAL_WINDOW_DEFAULT,
|
|
||||||
normalizeBodyChartDays,
|
normalizeBodyChartDays,
|
||||||
normalizeKpiKcalWindowDays,
|
|
||||||
} from '../widgetSystem/bodyChartDays'
|
} from '../widgetSystem/bodyChartDays'
|
||||||
|
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||||
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview', 'kpi_board'])
|
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview'])
|
||||||
|
|
||||||
function catalogMetaById(catalog) {
|
function catalogMetaById(catalog) {
|
||||||
if (!catalog?.widgets?.length) return {}
|
if (!catalog?.widgets?.length) return {}
|
||||||
|
|
@ -39,12 +38,9 @@ export default function DashboardLabPage() {
|
||||||
const metaById = catalogMetaById(catalog)
|
const metaById = catalogMetaById(catalog)
|
||||||
|
|
||||||
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||||
const clamped =
|
const clamped = normalizeBodyChartDays(
|
||||||
widgetId === 'kpi_board'
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||||
? normalizeKpiKcalWindowDays(draftStr === '' || draftStr == null ? null : draftStr)
|
)
|
||||||
: normalizeBodyChartDays(
|
|
||||||
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
|
||||||
)
|
|
||||||
return {
|
return {
|
||||||
...baseLayout,
|
...baseLayout,
|
||||||
widgets: baseLayout.widgets.map((x) =>
|
widgets: baseLayout.widgets.map((x) =>
|
||||||
|
|
@ -156,8 +152,8 @@ export default function DashboardLabPage() {
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
||||||
<strong>Körper</strong>, <strong>Aktivität</strong>, <strong>KPI Ø-Kalorien</strong>: 7–90 Tage). Layout pro
|
<strong>Körper</strong> / <strong>Aktivität</strong>: Zeitraum 7–90 Tage; <strong>KPI</strong>: Kacheln
|
||||||
Profil in der DB —
|
wählen & sortieren). Layout pro Profil in der DB —
|
||||||
getrennt vom Produktiv-Dashboard.
|
getrennt vom Produktiv-Dashboard.
|
||||||
Vergleich:{' '}
|
Vergleich:{' '}
|
||||||
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||||
|
|
@ -189,11 +185,10 @@ export default function DashboardLabPage() {
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||||
{layout.widgets.map((w, i) => {
|
{layout.widgets.map((w, i) => {
|
||||||
const label = metaById[w.id]?.title || w.id
|
const label = metaById[w.id]?.title || w.id
|
||||||
const chartDaysFallback = w.id === 'kpi_board' ? KPI_KCAL_WINDOW_DEFAULT : BODY_CHART_DAYS_DEFAULT
|
|
||||||
const chartDaysVal =
|
const chartDaysVal =
|
||||||
w.config?.chart_days != null
|
w.config?.chart_days != null
|
||||||
? normalizeBodyChartDays(w.config.chart_days)
|
? normalizeBodyChartDays(w.config.chart_days)
|
||||||
: chartDaysFallback
|
: BODY_CHART_DAYS_DEFAULT
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={w.id}
|
key={w.id}
|
||||||
|
|
@ -239,14 +234,34 @@ export default function DashboardLabPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{w.id === 'kpi_board' && (
|
||||||
|
<KpiBoardConfigEditor
|
||||||
|
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
|
||||||
|
onChange={(next) =>
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => {
|
||||||
|
if (j !== i) return x
|
||||||
|
const cfg = { ...(x.config || {}) }
|
||||||
|
if (next === undefined) {
|
||||||
|
delete cfg.tiles
|
||||||
|
} else {
|
||||||
|
cfg.tiles = next
|
||||||
|
}
|
||||||
|
return { ...x, config: cfg }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
||||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||||
{w.id === 'body_overview'
|
{w.id === 'body_overview'
|
||||||
? 'Körper-Chart'
|
? 'Körper-Chart'
|
||||||
: w.id === 'activity_overview'
|
: 'Aktivität (Verteilung & Konsistenz)'}{' '}
|
||||||
? 'Aktivität (Verteilung & Konsistenz)'
|
|
||||||
: 'KPI Ø-Kalorien (Ernährung)'}{' '}
|
|
||||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -258,9 +273,7 @@ export default function DashboardLabPage() {
|
||||||
aria-label={
|
aria-label={
|
||||||
w.id === 'body_overview'
|
w.id === 'body_overview'
|
||||||
? 'Körper-Chart Zeitraum in Tagen'
|
? 'Körper-Chart Zeitraum in Tagen'
|
||||||
: w.id === 'activity_overview'
|
: 'Aktivität Zeitraum in Tagen'
|
||||||
? 'Aktivität Zeitraum in Tagen'
|
|
||||||
: 'KPI Ø-Kalorien Fenster in Tagen'
|
|
||||||
}
|
}
|
||||||
value={
|
value={
|
||||||
chartDaysDraftByWidgetId[w.id] !== undefined
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||||
|
|
|
||||||
153
frontend/src/widgetSystem/KpiBoardConfigEditor.jsx
Normal file
153
frontend/src/widgetSystem/KpiBoardConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import { KPI_KCAL_WINDOW_DEFAULT } from './bodyChartDays'
|
||||||
|
import { KPI_TILE_AVG_KCAL, KPI_TILE_BODY_FAT, REF_TILE_PREFIX } from './kpiBoardTiles'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ tiles: { id: string }[] | undefined, onChange: (next: { id: string }[] | undefined) => void }} props
|
||||||
|
* undefined tiles = automatisch (kein config.tiles)
|
||||||
|
*/
|
||||||
|
export default function KpiBoardConfigEditor({ tiles, onChange }) {
|
||||||
|
const [catalog, setCatalog] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ok = true
|
||||||
|
api
|
||||||
|
.listProfileReferenceValuesSummary()
|
||||||
|
.then((s) => {
|
||||||
|
if (!ok) return
|
||||||
|
/** @type {{ id: string, label: string }[]} */
|
||||||
|
const opts = [
|
||||||
|
{ id: KPI_TILE_BODY_FAT, label: 'Körperfett (Caliper)' },
|
||||||
|
{ id: KPI_TILE_AVG_KCAL, label: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)` },
|
||||||
|
]
|
||||||
|
const list = Array.isArray(s?.tiles) ? s.tiles : []
|
||||||
|
for (const t of list) {
|
||||||
|
if (t?.type_key) {
|
||||||
|
opts.push({
|
||||||
|
id: `${REF_TILE_PREFIX}${t.type_key}`,
|
||||||
|
label: t.type_label || t.type_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCatalog(opts)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (ok) setCatalog([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const labelById = useMemo(() => Object.fromEntries(catalog.map((c) => [c.id, c.label])), [catalog])
|
||||||
|
|
||||||
|
const ordered = Array.isArray(tiles) ? tiles : []
|
||||||
|
|
||||||
|
const toggle = (id, checked) => {
|
||||||
|
if (checked) {
|
||||||
|
if (ordered.some((t) => t.id === id) || ordered.length >= 9) return
|
||||||
|
onChange([...ordered, { id }])
|
||||||
|
} else {
|
||||||
|
const next = ordered.filter((t) => t.id !== id)
|
||||||
|
onChange(next.length ? next : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (index, delta) => {
|
||||||
|
const j = index + delta
|
||||||
|
if (j < 0 || j >= ordered.length) return
|
||||||
|
const next = [...ordered]
|
||||||
|
const tmp = next[index]
|
||||||
|
next[index] = next[j]
|
||||||
|
next[j] = tmp
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||||
|
<strong>KPI-Kacheln:</strong> wählen und sortieren (max. 9). Ohne Auswahl oder „Automatisch“ =
|
||||||
|
bisherige automatische Belegung.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginBottom: 10, fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => onChange(undefined)}
|
||||||
|
>
|
||||||
|
Automatisch (wie bisher)
|
||||||
|
</button>
|
||||||
|
{ordered.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Reihenfolge (oben zuerst)
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{ordered.map((t, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${t.id}-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>{labelById[t.id] || t.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
onClick={() => move(idx, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
onClick={() => move(idx, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: 8,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{catalog.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ordered.some((t) => t.id === c.id)}
|
||||||
|
onChange={(e) => toggle(c.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -9,10 +9,5 @@ export function normalizeBodyChartDays(raw) {
|
||||||
return Math.min(BODY_CHART_DAYS_MAX, Math.max(BODY_CHART_DAYS_MIN, Math.round(n)))
|
return Math.min(BODY_CHART_DAYS_MAX, Math.max(BODY_CHART_DAYS_MIN, Math.round(n)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KPI-Board Ø-Kalorien: gleiche Grenzen, Default 7 Tage (bisheriges Verhalten ohne config). */
|
/** KPI-Board Ø-Kalorien: festes Analysefenster (nicht mehr über Layout-Config). */
|
||||||
export const KPI_KCAL_WINDOW_DEFAULT = 7
|
export const KPI_KCAL_WINDOW_DEFAULT = 7
|
||||||
|
|
||||||
export function normalizeKpiKcalWindowDays(raw) {
|
|
||||||
if (raw == null || raw === '') return KPI_KCAL_WINDOW_DEFAULT
|
|
||||||
return normalizeBodyChartDays(raw)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
22
frontend/src/widgetSystem/kpiBoardTiles.js
Normal file
22
frontend/src/widgetSystem/kpiBoardTiles.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** Feste KPI-Kachel-IDs (sync mit backend dashboard_widget_config). */
|
||||||
|
export const KPI_TILE_BODY_FAT = 'body_fat'
|
||||||
|
export const KPI_TILE_AVG_KCAL = 'avg_kcal'
|
||||||
|
|
||||||
|
export const REF_TILE_PREFIX = 'ref:'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown> | undefined} config
|
||||||
|
* @returns {string[] | undefined} undefined = automatische Kachelwahl (Legacy)
|
||||||
|
*/
|
||||||
|
export function kpiTileOrderFromConfig(config) {
|
||||||
|
if (!config || !Object.prototype.hasOwnProperty.call(config, 'tiles')) return undefined
|
||||||
|
const raw = config.tiles
|
||||||
|
if (!Array.isArray(raw)) return undefined
|
||||||
|
/** @type {string[]} */
|
||||||
|
const ids = []
|
||||||
|
for (const item of raw) {
|
||||||
|
const id = typeof item === 'string' ? item : item && item.id
|
||||||
|
if (typeof id === 'string' && id.trim()) ids.push(id.trim())
|
||||||
|
}
|
||||||
|
return ids.slice(0, 9)
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
||||||
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||||
import PilotBodySection from '../components/pilot/PilotBodySection'
|
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||||
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
||||||
import { normalizeBodyChartDays, normalizeKpiKcalWindowDays } from './bodyChartDays'
|
import { normalizeBodyChartDays } from './bodyChartDays'
|
||||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||||
|
|
||||||
let _registered = false
|
let _registered = false
|
||||||
|
|
@ -30,7 +30,7 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
Component: PilotKpiBoard,
|
Component: PilotKpiBoard,
|
||||||
mapProps: (ctx) => ({
|
mapProps: (ctx) => ({
|
||||||
refreshTick: ctx.refreshTick,
|
refreshTick: ctx.refreshTick,
|
||||||
kcalWindowDays: normalizeKpiKcalWindowDays(ctx.layoutEntry?.config?.chart_days),
|
kpiConfig: ctx.layoutEntry?.config || {},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
registerDashboardWidget({
|
registerDashboardWidget({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user