feat: Extend widget configuration for KPI board and enhance validation
- Added support for the "kpi_board" widget in the dashboard configuration, allowing for chart_days validation. - Updated the widget catalog description to reflect the new configuration options for KPI tiles. - Enhanced the DashboardLabPage to manage chart_days input for the KPI board, improving user experience. - Introduced normalization functions for KPI kcal window days to maintain consistent behavior. - Bumped app_dashboard version to 1.4.0 to reflect these enhancements.
This commit is contained in:
parent
97f9aa696e
commit
de99856a28
|
|
@ -11,7 +11,7 @@ from typing import Any
|
||||||
|
|
||||||
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
MAX_WIDGET_CONFIG_JSON_BYTES = 1024
|
||||||
|
|
||||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview"})
|
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview", "kpi_board"})
|
||||||
|
|
||||||
|
|
||||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
|
|
@ -35,6 +35,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
return _validate_chart_days_only(raw, label="body_overview")
|
return _validate_chart_days_only(raw, label="body_overview")
|
||||||
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":
|
||||||
|
return _validate_chart_days_only(raw, label="kpi_board")
|
||||||
|
|
||||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ def test_body_chart_days_bounds():
|
||||||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||||
|
|
||||||
|
|
||||||
def test_welcome_config_rejected():
|
def test_welcome_config_rejected_unknown_key():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("welcome", {"x": 1})
|
validate_widget_entry_config("welcome", {"x": 1})
|
||||||
|
|
||||||
|
|
@ -30,9 +30,16 @@ 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_config_rejected():
|
def test_kpi_board_chart_days():
|
||||||
|
assert validate_widget_entry_config("kpi_board", {}) == {}
|
||||||
|
assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {"chart_days": 14}
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("kpi_board", {"chart_days": 30})
|
validate_widget_entry_config("kpi_board", {"chart_days": 5})
|
||||||
|
|
||||||
|
|
||||||
|
def test_welcome_still_rejects_config():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_widget_entry_config("welcome", {"chart_days": 30})
|
||||||
|
|
||||||
|
|
||||||
def test_layout_payload_with_chart_days_roundtrip():
|
def test_layout_payload_with_chart_days_roundtrip():
|
||||||
|
|
|
||||||
|
|
@ -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.3.0", # activity_overview.chart_days + WidgetErrorBoundary
|
"app_dashboard": "1.4.0", # kpi_board.config.chart_days (Ø-Kalorien Fenster)
|
||||||
}
|
}
|
||||||
|
|
||||||
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",
|
"description": "Referenzwerte, KF%, Ø-Kalorien (optional: chart_days 7–90 für Ernährungsfenster)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "body_overview",
|
"id": "body_overview",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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'
|
||||||
|
|
||||||
const MAX_KPI = 9
|
const MAX_KPI = 9
|
||||||
|
|
||||||
|
|
@ -16,9 +17,9 @@ function formatRefVal(row) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* KPIs: Referenzwerte (Layer-1-Summary) + Körperfett % + Ø Kalorien 7T — max. 9 Kacheln.
|
* KPIs: Referenzwerte (Layer-1-Summary) + Körperfett % + Ø Kalorien (Fenster konfigurierbar) — max. 9 Kacheln.
|
||||||
*/
|
*/
|
||||||
export default function PilotKpiBoard({ refreshTick = 0 }) {
|
export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KCAL_WINDOW_DEFAULT }) {
|
||||||
const { activeProfile } = useProfile()
|
const { activeProfile } = useProfile()
|
||||||
const sex = activeProfile?.sex || 'm'
|
const sex = activeProfile?.sex || 'm'
|
||||||
const [refs, setRefs] = useState([])
|
const [refs, setRefs] = useState([])
|
||||||
|
|
@ -32,15 +33,18 @@ export default function PilotKpiBoard({ refreshTick = 0 }) {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
const nutrLimit = Math.min(2000, Math.max(60, kcalWindowDays * 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(() => []),
|
||||||
api.listNutrition(30).catch(() => []),
|
api.listNutrition(nutrLimit).catch(() => []),
|
||||||
])
|
])
|
||||||
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 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((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD'))
|
const recentNutr = (nutrition || []).filter(
|
||||||
|
(n) => n.date >= dayjs().subtract(kcalWindowDays, 'day').format('YYYY-MM-DD'),
|
||||||
|
})
|
||||||
const kcal =
|
const kcal =
|
||||||
recentNutr.length > 0
|
recentNutr.length > 0
|
||||||
? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length)
|
? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length)
|
||||||
|
|
@ -74,7 +78,7 @@ export default function PilotKpiBoard({ refreshTick = 0 }) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [refreshTick, sex])
|
}, [refreshTick, sex, kcalWindowDays])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,7 +128,9 @@ export default function PilotKpiBoard({ refreshTick = 0 }) {
|
||||||
if (avgKcal != null) {
|
if (avgKcal != null) {
|
||||||
tiles.push(
|
tiles.push(
|
||||||
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Ø Kalorien (7T)</div>
|
<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: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ 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 { 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'])
|
const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview', 'kpi_board'])
|
||||||
|
|
||||||
function catalogMetaById(catalog) {
|
function catalogMetaById(catalog) {
|
||||||
if (!catalog?.widgets?.length) return {}
|
if (!catalog?.widgets?.length) return {}
|
||||||
|
|
@ -37,9 +39,12 @@ 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 = normalizeBodyChartDays(
|
const clamped =
|
||||||
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
widgetId === 'kpi_board'
|
||||||
)
|
? 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) =>
|
||||||
|
|
@ -151,7 +156,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> und <strong>Aktivität</strong>: Zeitraum 7–90 Tage). Layout pro Profil in der DB —
|
<strong>Körper</strong>, <strong>Aktivität</strong>, <strong>KPI Ø-Kalorien</strong>: 7–90 Tage). 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)' }}>
|
||||||
|
|
@ -183,8 +189,11 @@ 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 ? normalizeBodyChartDays(w.config.chart_days) : BODY_CHART_DAYS_DEFAULT
|
w.config?.chart_days != null
|
||||||
|
? normalizeBodyChartDays(w.config.chart_days)
|
||||||
|
: chartDaysFallback
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={w.id}
|
key={w.id}
|
||||||
|
|
@ -233,8 +242,12 @@ export default function DashboardLabPage() {
|
||||||
{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' ? 'Körper-Chart' : 'Aktivität (Verteilung & Konsistenz)'} — Zeitraum
|
{w.id === 'body_overview'
|
||||||
(Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
? 'Körper-Chart'
|
||||||
|
: w.id === 'activity_overview'
|
||||||
|
? 'Aktivität (Verteilung & Konsistenz)'
|
||||||
|
: 'KPI Ø-Kalorien (Ernährung)'}{' '}
|
||||||
|
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -245,7 +258,9 @@ 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'
|
||||||
: 'Aktivität Zeitraum in Tagen'
|
: w.id === 'activity_overview'
|
||||||
|
? 'Aktivität Zeitraum in Tagen'
|
||||||
|
: 'KPI Ø-Kalorien Fenster in Tagen'
|
||||||
}
|
}
|
||||||
value={
|
value={
|
||||||
chartDaysDraftByWidgetId[w.id] !== undefined
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/** Körper-Chart: gültiger Bereich (sync mit backend dashboard_widget_config body_overview). */
|
/** Körper-/Aktivitäts-Chart: gültiger Bereich (sync mit backend dashboard_widget_config). */
|
||||||
export const BODY_CHART_DAYS_MIN = 7
|
export const BODY_CHART_DAYS_MIN = 7
|
||||||
export const BODY_CHART_DAYS_MAX = 90
|
export const BODY_CHART_DAYS_MAX = 90
|
||||||
export const BODY_CHART_DAYS_DEFAULT = 30
|
export const BODY_CHART_DAYS_DEFAULT = 30
|
||||||
|
|
@ -8,3 +8,11 @@ export function normalizeBodyChartDays(raw) {
|
||||||
if (!Number.isFinite(n)) return BODY_CHART_DAYS_DEFAULT
|
if (!Number.isFinite(n)) return BODY_CHART_DAYS_DEFAULT
|
||||||
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). */
|
||||||
|
export const KPI_KCAL_WINDOW_DEFAULT = 7
|
||||||
|
|
||||||
|
export function normalizeKpiKcalWindowDays(raw) {
|
||||||
|
if (raw == null || raw === '') return KPI_KCAL_WINDOW_DEFAULT
|
||||||
|
return normalizeBodyChartDays(raw)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 } from './bodyChartDays'
|
import { normalizeBodyChartDays, normalizeKpiKcalWindowDays } from './bodyChartDays'
|
||||||
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||||
|
|
||||||
let _registered = false
|
let _registered = false
|
||||||
|
|
@ -28,7 +28,10 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
registerDashboardWidget({
|
registerDashboardWidget({
|
||||||
id: 'kpi_board',
|
id: 'kpi_board',
|
||||||
Component: PilotKpiBoard,
|
Component: PilotKpiBoard,
|
||||||
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
kcalWindowDays: normalizeKpiKcalWindowDays(ctx.layoutEntry?.config?.chart_days),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
registerDashboardWidget({
|
registerDashboardWidget({
|
||||||
id: 'body_overview',
|
id: 'body_overview',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user