diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index d164759..8c0513b 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -11,7 +11,7 @@ from typing import Any 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: @@ -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") if widget_id == "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") diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 39180dc..3fabacb 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -14,7 +14,7 @@ def test_body_chart_days_bounds(): 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): 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}) -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): - 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(): diff --git a/backend/version.py b/backend/version.py index b9fc054..c1838b7 100644 --- a/backend/version.py +++ b/backend/version.py @@ -30,7 +30,7 @@ MODULE_VERSIONS = { "importdata": "1.0.0", "membership": "2.1.0", "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 = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 6152496..ec9cdfb 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -30,7 +30,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "kpi_board", "title": "KPI-Kacheln", - "description": "Referenzwerte, KF%, Kalorien", + "description": "Referenzwerte, KF%, Ø-Kalorien (optional: chart_days 7–90 für Ernährungsfenster)", }, { "id": "body_overview", diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx index 0173ed7..7496b13 100644 --- a/frontend/src/components/pilot/PilotKpiBoard.jsx +++ b/frontend/src/components/pilot/PilotKpiBoard.jsx @@ -4,6 +4,7 @@ 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' 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 sex = activeProfile?.sex || 'm' const [refs, setRefs] = useState([]) @@ -32,15 +33,18 @@ export default function PilotKpiBoard({ refreshTick = 0 }) { ;(async () => { try { setLoading(true) + const nutrLimit = Math.min(2000, Math.max(60, kcalWindowDays * 5)) const [summary, calipers, nutrition] = await Promise.all([ api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })), api.listCaliper(3).catch(() => []), - api.listNutrition(30).catch(() => []), + api.listNutrition(nutrLimit).catch(() => []), ]) if (cancelled) return 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 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 = recentNutr.length > 0 ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) @@ -74,7 +78,7 @@ export default function PilotKpiBoard({ refreshTick = 0 }) { return () => { cancelled = true } - }, [refreshTick, sex]) + }, [refreshTick, sex, kcalWindowDays]) if (loading) { return ( @@ -124,7 +128,9 @@ export default function PilotKpiBoard({ refreshTick = 0 }) { if (avgKcal != null) { tiles.push(
-
Ø Kalorien (7T)
+
+ Ø Kalorien ({kcalWindowDays}T) +
{avgKcal} kcal
Ernährung
, diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 747d5f1..e50189d 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -8,12 +8,14 @@ import { BODY_CHART_DAYS_DEFAULT, BODY_CHART_DAYS_MAX, BODY_CHART_DAYS_MIN, + KPI_KCAL_WINDOW_DEFAULT, normalizeBodyChartDays, + normalizeKpiKcalWindowDays, } from '../widgetSystem/bodyChartDays' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' /** 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) { if (!catalog?.widgets?.length) return {} @@ -37,9 +39,12 @@ export default function DashboardLabPage() { const metaById = catalogMetaById(catalog) const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => { - const clamped = normalizeBodyChartDays( - draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr - ) + const clamped = + widgetId === 'kpi_board' + ? normalizeKpiKcalWindowDays(draftStr === '' || draftStr == null ? null : draftStr) + : normalizeBodyChartDays( + draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr + ) return { ...baseLayout, widgets: baseLayout.widgets.map((x) => @@ -151,7 +156,8 @@ export default function DashboardLabPage() {

Widget-System: Katalog, Registry, Renderer; optional pro Widget config (z. B.{' '} - Körper und Aktivität: Zeitraum 7–90 Tage). Layout pro Profil in der DB — + Körper, Aktivität, KPI Ø-Kalorien: 7–90 Tage). Layout pro + Profil in der DB — getrennt vom Produktiv-Dashboard. Vergleich:{' '} @@ -183,8 +189,11 @@ export default function DashboardLabPage() {