diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index d0b30c4..60b099c 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -7,8 +7,9 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator +from dashboard_widget_config import validate_widget_entry_config from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG # Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul) @@ -31,6 +32,21 @@ def default_layout_dict() -> dict[str, Any]: class DashboardWidgetEntry(BaseModel): id: str = Field(min_length=1, max_length=64) enabled: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + @field_validator("config", mode="before") + @classmethod + def _config_coerce(cls, v: Any) -> dict[str, Any]: + if v is None: + return {} + if not isinstance(v, dict): + raise ValueError("config muss Objekt sein") + return v + + @model_validator(mode="after") + def _normalize_widget_config(self) -> DashboardWidgetEntry: + normalized = validate_widget_entry_config(self.id, self.config) + return self.model_copy(update={"config": normalized}) class DashboardLayoutPayload(BaseModel): @@ -50,10 +66,13 @@ class DashboardLayoutPayload(BaseModel): return self def to_stored_dict(self) -> dict[str, Any]: - return { - "version": self.version, - "widgets": [{"id": w.id, "enabled": w.enabled} for w in self.widgets], - } + out_widgets: list[dict[str, Any]] = [] + for w in self.widgets: + d: dict[str, Any] = {"id": w.id, "enabled": w.enabled} + if w.config: + d["config"] = dict(w.config) + out_widgets.append(d) + return {"version": self.version, "widgets": out_widgets} def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]: diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py new file mode 100644 index 0000000..80f9b00 --- /dev/null +++ b/backend/dashboard_widget_config.py @@ -0,0 +1,64 @@ +""" +Pro-Widget-Konfiguration im Dashboard-Layout (v1). + +Nur ausgewählte Widget-IDs dürfen nicht-leere config haben; bekannte Keys werden validiert. +""" +from __future__ import annotations + +import json +import math +from typing import Any + +MAX_WIDGET_CONFIG_JSON_BYTES = 1024 + +WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview"}) + + +def _config_json_size_bytes(config: dict[str, Any]) -> int: + return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) + + +def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein") + if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES: + raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)") + if not raw: + return {} + + if widget_id not in WIDGETS_ALLOWING_CONFIG: + raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + + if widget_id == "body_overview": + return _validate_body_overview_config(raw) + + raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + + +def _validate_body_overview_config(raw: dict[str, Any]) -> dict[str, Any]: + allowed = frozenset({"chart_days"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"body_overview: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, Any] = {} + if "chart_days" not in raw: + return out + v = raw["chart_days"] + if isinstance(v, bool): + raise ValueError("body_overview: chart_days muss ganze Zahl sein") + if isinstance(v, float): + if not math.isfinite(v): + raise ValueError("body_overview: chart_days muss ganze Zahl sein") + if abs(v - round(v)) > 1e-9: + raise ValueError("body_overview: chart_days muss ganze Zahl sein") + v = int(round(v)) + elif isinstance(v, int): + pass + else: + raise ValueError("body_overview: chart_days muss ganze Zahl sein") + if v < 7 or v > 90: + raise ValueError("body_overview: chart_days muss zwischen 7 und 90 liegen") + out["chart_days"] = v + return out diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py new file mode 100644 index 0000000..ba6f7fe --- /dev/null +++ b/backend/tests/test_dashboard_widget_config.py @@ -0,0 +1,54 @@ +import pytest + +from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict +from dashboard_widget_config import validate_widget_entry_config + + +def test_body_chart_days_bounds(): + assert validate_widget_entry_config("body_overview", {"chart_days": 7}) == {"chart_days": 7} + assert validate_widget_entry_config("body_overview", {"chart_days": 90}) == {"chart_days": 90} + assert validate_widget_entry_config("body_overview", {"chart_days": 42.0}) == {"chart_days": 42} + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 6}) + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 91}) + + +def test_welcome_config_rejected(): + with pytest.raises(ValueError): + validate_widget_entry_config("welcome", {"x": 1}) + + +def test_body_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1}) + + +def test_layout_payload_with_chart_days_roundtrip(): + p = DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + { + "id": "body_overview", + "enabled": True, + "config": {"chart_days": 42}, + }, + ], + } + ) + d = p.to_stored_dict() + assert d["widgets"][1]["config"]["chart_days"] == 42 + + +def test_coalesce_rejects_invalid_widget_config(): + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True, "config": {"evil": True}}, + ], + } + custom, eff = coalesce_effective_layout(raw) + assert custom is False + assert eff == default_layout_dict() diff --git a/backend/version.py b/backend/version.py index 20a4d2c..5c0e287 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.1.0", # Dashboard-Lab + GET /widgets/catalog (Widget-System Iteration 1) + "app_dashboard": "1.2.0", # Widget-Config (body_overview.chart_days) + Validierung } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 210d398..5c67966 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -35,7 +35,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "body_overview", "title": "Körper (Chart)", - "description": "Gewicht & Kennzahlen", + "description": "Gewicht & Kennzahlen (optional: config chart_days 7–90)", }, { "id": "activity_overview", diff --git a/frontend/src/components/pilot/PilotBodySection.jsx b/frontend/src/components/pilot/PilotBodySection.jsx index 22313af..18a29f6 100644 --- a/frontend/src/components/pilot/PilotBodySection.jsx +++ b/frontend/src/components/pilot/PilotBodySection.jsx @@ -16,11 +16,14 @@ import { api } from '../../utils/api' import { useProfile } from '../../context/ProfileContext' import { getInterpretation } from '../../utils/interpret' import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils' +import { + BODY_CHART_DAYS_DEFAULT, + normalizeBodyChartDays, +} from '../../widgetSystem/bodyChartDays' import PilotRuleCard from './PilotRuleCard' -const WINDOW_DAYS = 30 - -export default function PilotBodySection({ refreshTick = 0 }) { +export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) { + const windowDays = normalizeBodyChartDays(chartDays) const { activeProfile } = useProfile() const [weights, setWeights] = useState([]) const [calipers, setCalipers] = useState([]) @@ -31,10 +34,11 @@ export default function PilotBodySection({ refreshTick = 0 }) { let cancelled = false ;(async () => { try { + const fetchDays = Math.max(120, windowDays + 60) const [w, ca, ci] = await Promise.all([ - api.listWeight(120), - api.listCaliper(30), - api.listCirc(30), + api.listWeight(fetchDays), + api.listCaliper(Math.max(30, windowDays)), + api.listCirc(Math.max(30, windowDays)), ]) if (!cancelled) { setWeights(Array.isArray(w) ? w : []) @@ -54,11 +58,9 @@ export default function PilotBodySection({ refreshTick = 0 }) { return () => { cancelled = true } - }, [refreshTick]) + }, [refreshTick, windowDays]) - const sex = activeProfile?.sex || 'm' - const height = activeProfile?.height || 178 - const cutoff = dayjs().subtract(WINDOW_DAYS, 'day').format('YYYY-MM-DD') + const cutoff = dayjs().subtract(windowDays, 'day').format('YYYY-MM-DD') const filtW = [...(weights || [])] .sort((a, b) => a.date.localeCompare(b.date)) @@ -111,7 +113,7 @@ export default function PilotBodySection({ refreshTick = 0 }) { >
- Fokus letzte {WINDOW_DAYS} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf + Fokus letzte {windowDays} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
@@ -128,7 +130,7 @@ export default function PilotBodySection({ refreshTick = 0 }) {
- Widget-System (Iteration 1): Katalog vom Server, Registry im Frontend, Renderer für alle
- Pilot-Module. Layout wird pro Profil persistiert — getrennt vom Produktiv-Dashboard. Vergleich:{' '}
+ Widget-System: Katalog, Registry, Renderer; optional pro Widget config (z. B.{' '}
+ Körper-Chart 7–90 Tage). Layout pro Profil in der DB — getrennt vom Produktiv-Dashboard.
+ Vergleich:{' '}
Pilot-Übersicht (festes Standard-Layout)
@@ -146,46 +153,94 @@ export default function DashboardLabPage() {