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 }) { >

Bereich Körper

- 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 }) {
- Gewicht · {filtW.length} Messungen ({WINDOW_DAYS}T) + Gewicht · {filtW.length} Messungen ({windowDays}T)
Verlauf Körper diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index ac09de2..8aca6ba 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -4,7 +4,13 @@ import { Link } from 'react-router-dom' import { api, formatFastApiDetail } from '../utils/api' import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry' import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets' -import { moveWidget, toggleWidget } from '../widgetSystem/layoutEditor' +import { + BODY_CHART_DAYS_DEFAULT, + BODY_CHART_DAYS_MAX, + BODY_CHART_DAYS_MIN, + normalizeBodyChartDays, +} from '../widgetSystem/bodyChartDays' +import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' function catalogMetaById(catalog) { if (!catalog?.widgets?.length) return {} @@ -31,7 +37,7 @@ export default function DashboardLabPage() { const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) setCatalog(cat) setBundle(b) - setLayout(b.layout) + setLayout(normalizeLayoutForEditor(b.layout)) } catch (e) { setErr(formatFastApiDetail(null, e.message)) } @@ -64,7 +70,7 @@ export default function DashboardLabPage() { setErr(null) try { const r = await api.resetAppDashboardLayout() - setLayout(r.layout) + setLayout(normalizeLayoutForEditor(r.layout)) setMsg('Auf Standard zurückgesetzt.') await load() } catch (e) { @@ -76,7 +82,7 @@ export default function DashboardLabPage() { const applyDefaultLocal = () => { if (bundle?.default_layout) { - setLayout(structuredClone(bundle.default_layout)) + setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout))) setMsg('Standard geladen (noch nicht gespeichert).') } } @@ -115,8 +121,9 @@ export default function DashboardLabPage() { App-Bereich: Dashboard-Lab

- 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() {

    {layout.widgets.map((w, i) => { const label = metaById[w.id]?.title || w.id + const chartDaysVal = + w.config?.chart_days != null ? normalizeBodyChartDays(w.config.chart_days) : BODY_CHART_DAYS_DEFAULT return (
  • - -
    - - +
    + +
    + + +
    + {w.id === 'body_overview' && ( +
    + + { + const v = parseInt(e.target.value, 10) + setLayout((L) => ({ + ...L, + widgets: L.widgets.map((x, j) => + j !== i + ? x + : { + ...x, + config: { + ...x.config, + chart_days: Number.isFinite(v) ? v : BODY_CHART_DAYS_DEFAULT, + }, + } + ), + })) + }} + onBlur={(e) => { + const clamped = normalizeBodyChartDays(e.target.value) + setLayout((L) => ({ + ...L, + widgets: L.widgets.map((x, j) => + j !== i ? x : { ...x, config: { ...x.config, chart_days: clamped } } + ), + })) + }} + /> +
    + )}
  • ) })} diff --git a/frontend/src/widgetSystem/bodyChartDays.js b/frontend/src/widgetSystem/bodyChartDays.js new file mode 100644 index 0000000..d230ee3 --- /dev/null +++ b/frontend/src/widgetSystem/bodyChartDays.js @@ -0,0 +1,10 @@ +/** Körper-Chart: gültiger Bereich (sync mit backend dashboard_widget_config body_overview). */ +export const BODY_CHART_DAYS_MIN = 7 +export const BODY_CHART_DAYS_MAX = 90 +export const BODY_CHART_DAYS_DEFAULT = 30 + +export function normalizeBodyChartDays(raw) { + const n = Number(raw) + 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))) +} diff --git a/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx index edb7ead..0d9e173 100644 --- a/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx +++ b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx @@ -1,4 +1,12 @@ -/** @typedef {{ refreshTick: number, requestRefresh: () => void }} WidgetRenderContext */ +/** + * @typedef {object} LayoutWidgetEntry + * @property {string} id + * @property {boolean} enabled + * @property {Record} [config] + */ +/** + * @typedef {{ refreshTick: number, requestRefresh: () => void, layoutEntry: LayoutWidgetEntry }} WidgetRenderContext + */ const registry = new Map() @@ -49,12 +57,21 @@ export function renderRegisteredWidget(id, ctx) { /** * Rendert alle aktivierten Widgets in Layout-Reihenfolge. - * @param {{ version: number, widgets: Array<{ id: string, enabled: boolean }> }} layout - * @param {WidgetRenderContext} ctx + * @param {{ version: number, widgets: Array }} layout + * @param {{ refreshTick: number, requestRefresh: () => void }} base */ export function WidgetRenderer({ layout, refreshTick, requestRefresh }) { if (!layout?.widgets?.length) return null - const ctx = { refreshTick, requestRefresh } const enabled = layout.widgets.filter((w) => w.enabled) - return <>{enabled.map((w) => renderRegisteredWidget(w.id, ctx))} + return ( + <> + {enabled.map((w) => + renderRegisteredWidget(w.id, { + refreshTick, + requestRefresh, + layoutEntry: w, + }) + )} + + ) } diff --git a/frontend/src/widgetSystem/layoutEditor.js b/frontend/src/widgetSystem/layoutEditor.js index 4737714..d866480 100644 --- a/frontend/src/widgetSystem/layoutEditor.js +++ b/frontend/src/widgetSystem/layoutEditor.js @@ -1,3 +1,14 @@ +export function normalizeLayoutForEditor(layout) { + if (!layout?.widgets) return layout + return { + ...layout, + widgets: layout.widgets.map((w) => ({ + ...w, + config: w.config && typeof w.config === 'object' ? { ...w.config } : {}, + })), + } +} + export function moveWidget(layout, index, delta) { const next = [...layout.widgets] const j = index + delta diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 5d21cdc..b7d634d 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -6,6 +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 } from './bodyChartDays' import { registerDashboardWidget } from './dashboardWidgetRegistry' let _registered = false @@ -32,7 +33,10 @@ export function ensurePilotLabWidgetsRegistered() { registerDashboardWidget({ id: 'body_overview', Component: PilotBodySection, - mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), + }), }) registerDashboardWidget({ id: 'activity_overview',