feat: Enhance Dashboard widget configuration and layout management
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

- Added validation for widget configuration in the DashboardWidgetEntry model to ensure proper data structure.
- Updated the DashboardLayoutPayload to include widget configuration in the serialized output.
- Improved the PilotBodySection and DashboardLabPage components to support dynamic chart days configuration for the body overview widget.
- Refactored layout editor functions to normalize widget configurations for better handling.
- Bumped app_dashboard version to 1.2.0 to reflect the new features and improvements.
This commit is contained in:
Lars 2026-04-07 11:58:07 +02:00
parent f6c5f96768
commit 87c4cbc4b4
11 changed files with 298 additions and 62 deletions

View File

@ -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]]:

View File

@ -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

View File

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

View File

@ -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 = [

View File

@ -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 790)",
},
{
"id": "activity_overview",

View File

@ -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 }) {
>
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Körper</h2>
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
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
</p>
</div>
@ -128,7 +130,7 @@ export default function PilotBodySection({ refreshTick = 0 }) {
<div className="card section-gap">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {filtW.length} Messungen ({WINDOW_DAYS}T)
Gewicht · {filtW.length} Messungen ({windowDays}T)
</div>
<Link to="/history" state={{ tab: 'body' }} className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px', textDecoration: 'none' }}>
Verlauf Körper <ChevronRight size={10} />

View File

@ -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
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
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 <code>config</code> (z.B.{' '}
<strong>Körper-Chart</strong> 790 Tage). Layout pro Profil in der DB getrennt vom Produktiv-Dashboard.
Vergleich:{' '}
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
Pilot-Übersicht (festes Standard-Layout)
</Link>
@ -146,46 +153,94 @@ export default function DashboardLabPage() {
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
{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 (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '8px 0',
borderBottom: '1px solid var(--border)',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
<input
type="checkbox"
checked={w.enabled}
onChange={() => setLayout((L) => toggleWidget(L, i))}
/>
<span style={{ fontSize: 14 }}>{label}</span>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach oben"
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
>
<ChevronUp size={18} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach unten"
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
>
<ChevronDown size={18} />
</button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
<input
type="checkbox"
checked={w.enabled}
onChange={() => setLayout((L) => toggleWidget(L, i))}
/>
<span style={{ fontSize: 14 }}>{label}</span>
</label>
<div style={{ display: 'flex', gap: 6 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach oben"
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
>
<ChevronUp size={18} />
</button>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '6px 10px' }}
aria-label="Nach unten"
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
>
<ChevronDown size={18} />
</button>
</div>
</div>
{w.id === 'body_overview' && (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Körper-Chart Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
type="number"
className="form-input"
style={{ maxWidth: 120 }}
min={BODY_CHART_DAYS_MIN}
max={BODY_CHART_DAYS_MAX}
value={chartDaysVal}
onChange={(e) => {
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 } }
),
}))
}}
/>
</div>
)}
</li>
)
})}

View File

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

View File

@ -1,4 +1,12 @@
/** @typedef {{ refreshTick: number, requestRefresh: () => void }} WidgetRenderContext */
/**
* @typedef {object} LayoutWidgetEntry
* @property {string} id
* @property {boolean} enabled
* @property {Record<string, unknown>} [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<LayoutWidgetEntry> }} 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,
})
)}
</>
)
}

View File

@ -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

View File

@ -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',