From c0c512e9425b7464e065572737a825622f717c35 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 7 Apr 2026 12:46:18 +0200 Subject: [PATCH] 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. --- backend/dashboard_widget_config.py | 56 +++++- backend/tests/test_dashboard_widget_config.py | 20 ++- backend/version.py | 2 +- backend/widget_catalog.py | 2 +- .../src/components/pilot/PilotKpiBoard.jsx | 164 ++++++++++++------ frontend/src/pages/DashboardLabPage.jsx | 51 ++++-- .../src/widgetSystem/KpiBoardConfigEditor.jsx | 153 ++++++++++++++++ frontend/src/widgetSystem/bodyChartDays.js | 7 +- frontend/src/widgetSystem/kpiBoardTiles.js | 22 +++ .../widgetSystem/registerPilotLabWidgets.js | 4 +- 10 files changed, 390 insertions(+), 91 deletions(-) create mode 100644 frontend/src/widgetSystem/KpiBoardConfigEditor.jsx create mode 100644 frontend/src/widgetSystem/kpiBoardTiles.js diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 8c0513b..273e387 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -7,12 +7,16 @@ from __future__ import annotations import json import math +import re 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"}) +_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: 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": return _validate_chart_days_only(raw, label="activity_overview") 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") +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: if isinstance(v, bool): raise ValueError(f"{label}: chart_days muss ganze Zahl sein") diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 3fabacb..f0eb5e5 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -30,11 +30,25 @@ def test_activity_chart_days(): 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", {"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): - 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(): diff --git a/backend/version.py b/backend/version.py index c1838b7..1988fe4 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.4.0", # kpi_board.config.chart_days (Ø-Kalorien Fenster) + "app_dashboard": "1.5.0", # kpi_board: Kachelwahl tiles statt chart_days } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index ec9cdfb..cb16c5a 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 (optional: chart_days 7–90 für Ernährungsfenster)", + "description": "Referenzwerte, KF%, Ø-Kalorien — optional Kacheln & Reihenfolge (config.tiles, max. 9)", }, { "id": "body_overview", diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx index e514ac5..02b3898 100644 --- a/frontend/src/components/pilot/PilotKpiBoard.jsx +++ b/frontend/src/components/pilot/PilotKpiBoard.jsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useCallback } 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' const MAX_KPI = 9 @@ -16,13 +17,33 @@ function formatRefVal(row) { 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 }} 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 sex = activeProfile?.sex || 'm' - const [refs, setRefs] = useState([]) + 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) @@ -33,7 +54,8 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC ;(async () => { try { 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([ api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })), api.listCaliper(3).catch(() => []), @@ -41,9 +63,10 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC ]) 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(kcalWindowDays, 'day').format('YYYY-MM-DD'), + (n) => n.date >= dayjs().subtract(kcalDays, 'day').format('YYYY-MM-DD'), ) const kcal = recentNutr.length > 0 @@ -52,9 +75,9 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC const wantBf = !!latestCal?.body_fat_pct const wantKcal = kcal != null && kcal > 0 - const extra = (wantBf ? 1 : 0) + (wantKcal ? 1 : 0) - const refCap = Math.max(0, MAX_KPI - extra) - setRefs(tiles.slice(0, refCap)) + + setRefTiles(tiles) + setRefByKey(map) setBf( wantBf ? { @@ -69,7 +92,8 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC } catch (e) { if (!cancelled) { setErr(e.message || 'KPIs konnten nicht geladen werden') - setRefs([]) + setRefTiles([]) + setRefByKey(new Map()) } } finally { if (!cancelled) setLoading(false) @@ -78,7 +102,73 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC return () => { 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( +
+
Körperfett
+
+ {bf.pct}% +
+
{bf.cat?.label || 'Caliper'}
+
, + ) + return + } + if (id === 'avg_kcal') { + if (avgKcal == null) return + out.push( +
+
+ Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T) +
+
{avgKcal} kcal
+
Ernährung
+
, + ) + return + } + const tk = parseRefTypeKey(id) + if (!tk) return + const tile = refByKey.get(tk) + if (!tile?.latest) return + const l = tile.latest + out.push( +
+
{tile.type_label}
+
+ {formatRefVal(l)} + {l.unit ? ( + {l.unit} + ) : null} +
+
Ref.wert
+
, + ) + }, + [bf, avgKcal, refByKey], + ) + + const visibleTiles = useMemo(() => { + const out = [] + for (const id of orderIds) { + pushTileForId(id, out) + } + return out + }, [orderIds, pushTileForId]) if (loading) { return ( @@ -95,54 +185,12 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC ) } - const tiles = [] - - refs.forEach((tile) => { - const l = tile.latest - tiles.push( -
-
{tile.type_label}
-
- {formatRefVal(l)} - {l.unit ? ( - {l.unit} - ) : null} -
-
Ref.wert
-
, - ) - }) - - if (bf) { - tiles.push( -
-
Körperfett
-
- {bf.pct}% -
-
{bf.cat?.label || 'Caliper'}
-
, - ) - } - - if (avgKcal != null) { - tiles.push( -
-
- Ø Kalorien ({kcalWindowDays}T) -
-
{avgKcal} kcal
-
Ernährung
-
, - ) - } - - if (tiles.length === 0) { + if (visibleTiles.length === 0) { return (
Kennzahlen

- Noch keine Daten.{' '} + Noch keine Daten oder keine passenden Kacheln.{' '} Referenzwerte @@ -164,9 +212,11 @@ export default function PilotKpiBoard({ refreshTick = 0, kcalWindowDays = KPI_KC

Kennzahlen

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

-
{tiles}
+
{visibleTiles}
) } diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index e50189d..0dea543 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -8,14 +8,13 @@ import { BODY_CHART_DAYS_DEFAULT, BODY_CHART_DAYS_MAX, BODY_CHART_DAYS_MIN, - KPI_KCAL_WINDOW_DEFAULT, normalizeBodyChartDays, - normalizeKpiKcalWindowDays, } from '../widgetSystem/bodyChartDays' +import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' 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', 'kpi_board']) +const CHART_DAYS_WIDGET_IDS = new Set(['body_overview', 'activity_overview']) function catalogMetaById(catalog) { if (!catalog?.widgets?.length) return {} @@ -39,12 +38,9 @@ export default function DashboardLabPage() { const metaById = catalogMetaById(catalog) const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => { - const clamped = - widgetId === 'kpi_board' - ? normalizeKpiKcalWindowDays(draftStr === '' || draftStr == null ? null : draftStr) - : normalizeBodyChartDays( - draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr - ) + const clamped = normalizeBodyChartDays( + draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr + ) return { ...baseLayout, widgets: baseLayout.widgets.map((x) => @@ -156,8 +152,8 @@ export default function DashboardLabPage() {

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

    {layout.widgets.map((w, i) => { const label = metaById[w.id]?.title || w.id - const chartDaysFallback = w.id === 'kpi_board' ? KPI_KCAL_WINDOW_DEFAULT : BODY_CHART_DAYS_DEFAULT const chartDaysVal = w.config?.chart_days != null ? normalizeBodyChartDays(w.config.chart_days) - : chartDaysFallback + : BODY_CHART_DAYS_DEFAULT return (
+ {w.id === 'kpi_board' && ( + + 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) && (
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 ( +
+
+ KPI-Kacheln: wählen und sortieren (max. 9). Ohne Auswahl oder „Automatisch“ = + bisherige automatische Belegung. +
+ + {ordered.length > 0 && ( +
+
+ Reihenfolge (oben zuerst) +
+
    + {ordered.map((t, idx) => ( +
  • + {labelById[t.id] || t.id} + + +
  • + ))} +
+
+ )} +
+ {catalog.map((c) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/widgetSystem/bodyChartDays.js b/frontend/src/widgetSystem/bodyChartDays.js index 1179c05..1658bb5 100644 --- a/frontend/src/widgetSystem/bodyChartDays.js +++ b/frontend/src/widgetSystem/bodyChartDays.js @@ -9,10 +9,5 @@ export function normalizeBodyChartDays(raw) { 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 function normalizeKpiKcalWindowDays(raw) { - if (raw == null || raw === '') return KPI_KCAL_WINDOW_DEFAULT - return normalizeBodyChartDays(raw) -} diff --git a/frontend/src/widgetSystem/kpiBoardTiles.js b/frontend/src/widgetSystem/kpiBoardTiles.js new file mode 100644 index 0000000..f81c4bb --- /dev/null +++ b/frontend/src/widgetSystem/kpiBoardTiles.js @@ -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 | 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) +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index ad0fe57..caf0da5 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -6,7 +6,7 @@ import PilotQuickCapture from '../components/pilot/PilotQuickCapture' import PilotKpiBoard from '../components/pilot/PilotKpiBoard' import PilotBodySection from '../components/pilot/PilotBodySection' import PilotActivitySection from '../components/pilot/PilotActivitySection' -import { normalizeBodyChartDays, normalizeKpiKcalWindowDays } from './bodyChartDays' +import { normalizeBodyChartDays } from './bodyChartDays' import { registerDashboardWidget } from './dashboardWidgetRegistry' let _registered = false @@ -30,7 +30,7 @@ export function ensurePilotLabWidgetsRegistered() { Component: PilotKpiBoard, mapProps: (ctx) => ({ refreshTick: ctx.refreshTick, - kcalWindowDays: normalizeKpiKcalWindowDays(ctx.layoutEntry?.config?.chart_days), + kpiConfig: ctx.layoutEntry?.config || {}, }), }) registerDashboardWidget({