diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index 80f9b00..d164759 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"}) +WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({"body_overview", "activity_overview"}) def _config_json_size_bytes(config: dict[str, Any]) -> int: @@ -32,33 +32,35 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") if widget_id == "body_overview": - return _validate_body_overview_config(raw) + return _validate_chart_days_only(raw, label="body_overview") + if widget_id == "activity_overview": + return _validate_chart_days_only(raw, label="activity_overview") raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") -def _validate_body_overview_config(raw: dict[str, Any]) -> dict[str, Any]: +def _parse_chart_days(v: Any, label: str) -> int: + if isinstance(v, bool): + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + if isinstance(v, float): + if not math.isfinite(v): + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + if abs(v - round(v)) > 1e-9: + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + return int(round(v)) + if isinstance(v, int): + return v + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + + +def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> 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] = {} + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") 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") + return {} + v = _parse_chart_days(raw["chart_days"], label) if v < 7 or v > 90: - raise ValueError("body_overview: chart_days muss zwischen 7 und 90 liegen") - out["chart_days"] = v - return out + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + return {"chart_days": v} diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index ba6f7fe..39180dc 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -24,6 +24,17 @@ def test_body_unknown_key(): validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1}) +def test_activity_chart_days(): + assert validate_widget_entry_config("activity_overview", {"chart_days": 14}) == {"chart_days": 14} + with pytest.raises(ValueError): + validate_widget_entry_config("activity_overview", {"chart_days": 5}) + + +def test_kpi_config_rejected(): + with pytest.raises(ValueError): + validate_widget_entry_config("kpi_board", {"chart_days": 30}) + + def test_layout_payload_with_chart_days_roundtrip(): p = DashboardLayoutPayload.model_validate( { diff --git a/backend/version.py b/backend/version.py index 5c0e287..5f465cd 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.2.0", # Widget-Config (body_overview.chart_days) + Validierung + "app_dashboard": "1.3.0", # activity_overview.chart_days + WidgetErrorBoundary } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 5c67966..6152496 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -40,7 +40,7 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [ { "id": "activity_overview", "title": "Aktivität", - "description": "Training & Konsistenz", + "description": "Training & Konsistenz (optional: config chart_days 7–90)", }, ] diff --git a/frontend/src/components/pilot/PilotActivitySection.jsx b/frontend/src/components/pilot/PilotActivitySection.jsx index fe3199d..f934d05 100644 --- a/frontend/src/components/pilot/PilotActivitySection.jsx +++ b/frontend/src/components/pilot/PilotActivitySection.jsx @@ -4,11 +4,14 @@ import dayjs from 'dayjs' import { api } from '../../utils/api' import { useProfile } from '../../context/ProfileContext' import TrainingTypeDistribution from '../TrainingTypeDistribution' +import { + BODY_CHART_DAYS_DEFAULT, + normalizeBodyChartDays, +} from '../../widgetSystem/bodyChartDays' import PilotRuleCard from './PilotRuleCard' -const PERIOD = 30 - -export default function PilotActivitySection({ refreshTick = 0 }) { +export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) { + const periodDays = normalizeBodyChartDays(chartDays) const { activeProfile } = useProfile() const globalQualityLevel = activeProfile?.quality_filter_level const [activities, setActivities] = useState([]) @@ -18,7 +21,8 @@ export default function PilotActivitySection({ refreshTick = 0 }) { let cancelled = false ;(async () => { try { - const a = await api.listActivity(120) + const fetchDays = Math.max(120, periodDays + 60) + const a = await api.listActivity(fetchDays) if (!cancelled) setActivities(Array.isArray(a) ? a : []) } catch { if (!cancelled) setActivities([]) @@ -29,15 +33,15 @@ export default function PilotActivitySection({ refreshTick = 0 }) { return () => { cancelled = true } - }, [refreshTick, globalQualityLevel]) + }, [refreshTick, globalQualityLevel, periodDays]) - const cutoff = dayjs().subtract(PERIOD, 'day').format('YYYY-MM-DD') + const cutoff = dayjs().subtract(periodDays, 'day').format('YYYY-MM-DD') const filtA = (activities || []).filter((d) => d.date >= cutoff) const daysWithAct = new Set(filtA.map((a) => a.date)).size const totalDays = filtA.length > 0 - ? Math.min(PERIOD, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1) + ? Math.min(periodDays, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1) : 0 const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0 @@ -46,7 +50,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) { status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad', icon: '📅', category: 'Konsistenz', - title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(PERIOD, totalDays || PERIOD)} Tage)`, + title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(periodDays, totalDays || periodDays)} Tage)`, detail: consistency >= 70 ? 'Ausgezeichnete Regelmäßigkeit.' @@ -77,7 +81,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) { >

Bereich Aktivität

- Trainingstyp-Verteilung {PERIOD} Tage · Bewertung Konsistenz wie im Verlauf + Trainingstyp-Verteilung {periodDays} Tage · Bewertung Konsistenz wie im Verlauf

@@ -102,7 +106,7 @@ export default function PilotActivitySection({ refreshTick = 0 }) {
Trainingstyp-Verteilung
- +
Vollständiger Verlauf Aktivität → diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index ba246f7..747d5f1 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -12,6 +12,9 @@ import { } 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']) + function catalogMetaById(catalog) { if (!catalog?.widgets?.length) return {} return Object.fromEntries(catalog.widgets.map((w) => [w.id, w])) @@ -28,17 +31,19 @@ export default function DashboardLabPage() { const [err, setErr] = useState(null) const [busy, setBusy] = useState(false) const [msg, setMsg] = useState(null) - /** Während der Fokus im Körper-Chart-Feld: Rohstring, damit Tippen (z. B. „14“) nicht sofort geclamped wird */ - const [bodyChartDaysDraft, setBodyChartDaysDraft] = useState(null) + /** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */ + const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({}) const metaById = catalogMetaById(catalog) - const commitBodyChartDraftToLayout = useCallback((draftStr, baseLayout) => { - const clamped = normalizeBodyChartDays(draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr) + const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => { + const clamped = normalizeBodyChartDays( + draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr + ) return { ...baseLayout, widgets: baseLayout.widgets.map((x) => - x.id !== 'body_overview' ? x : { ...x, config: { ...x.config, chart_days: clamped } } + x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } } ), } }, []) @@ -49,7 +54,7 @@ export default function DashboardLabPage() { const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) setCatalog(cat) setBundle(b) - setBodyChartDaysDraft(null) + setChartDaysDraftByWidgetId({}) setLayout(normalizeLayoutForEditor(b.layout)) } catch (e) { setErr(formatFastApiDetail(null, e.message)) @@ -63,10 +68,13 @@ export default function DashboardLabPage() { const save = async () => { if (!layout) return let toSave = layout - if (bodyChartDaysDraft !== null) { - toSave = normalizeLayoutForEditor(commitBodyChartDraftToLayout(bodyChartDaysDraft, layout)) + const draftEntries = Object.entries(chartDaysDraftByWidgetId) + if (draftEntries.length) { + for (const [wid, val] of draftEntries) { + toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid)) + } setLayout(toSave) - setBodyChartDaysDraft(null) + setChartDaysDraftByWidgetId({}) } setBusy(true) setMsg(null) @@ -89,7 +97,7 @@ export default function DashboardLabPage() { setErr(null) try { const r = await api.resetAppDashboardLayout() - setBodyChartDaysDraft(null) + setChartDaysDraftByWidgetId({}) setLayout(normalizeLayoutForEditor(r.layout)) setMsg('Auf Standard zurückgesetzt.') await load() @@ -102,7 +110,7 @@ export default function DashboardLabPage() { const applyDefaultLocal = () => { if (bundle?.default_layout) { - setBodyChartDaysDraft(null) + setChartDaysDraftByWidgetId({}) setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout))) setMsg('Standard geladen (noch nicht gespeichert).') } @@ -143,7 +151,8 @@ export default function DashboardLabPage() {

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. + Körper und Aktivität: Zeitraum 7–90 Tage). Layout pro Profil in der DB — + getrennt vom Produktiv-Dashboard. Vergleich:{' '} Pilot-Übersicht (festes Standard-Layout) @@ -221,10 +230,11 @@ export default function DashboardLabPage() {

- {w.id === 'body_overview' && ( + {CHART_DAYS_WIDGET_IDS.has(w.id) && (
setBodyChartDaysDraft(String(chartDaysVal))} - onChange={(e) => setBodyChartDaysDraft(e.target.value)} - onBlur={() => { + onFocus={() => + setChartDaysDraftByWidgetId((prev) => ({ + ...prev, + [w.id]: String(chartDaysVal), + })) + } + onChange={(e) => + setChartDaysDraftByWidgetId((prev) => ({ + ...prev, + [w.id]: e.target.value, + })) + } + onBlur={(e) => { + const raw = e.target.value setLayout((L) => - normalizeLayoutForEditor( - commitBodyChartDraftToLayout(bodyChartDaysDraft ?? String(chartDaysVal), L) - ) + normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id)) ) - setBodyChartDaysDraft(null) + setChartDaysDraftByWidgetId((prev) => { + const next = { ...prev } + delete next[w.id] + return next + }) }} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() diff --git a/frontend/src/widgetSystem/WidgetErrorBoundary.jsx b/frontend/src/widgetSystem/WidgetErrorBoundary.jsx new file mode 100644 index 0000000..8b6c96c --- /dev/null +++ b/frontend/src/widgetSystem/WidgetErrorBoundary.jsx @@ -0,0 +1,51 @@ +import { Component } from 'react' + +/** + * Verhindert, dass ein fehlerhaftes Dashboard-Widget die ganze Seite mitreißt. + */ +export default class WidgetErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error) { + return { error } + } + + render() { + if (this.state.error) { + const msg = + this.state.error && typeof this.state.error === 'object' && 'message' in this.state.error + ? String(this.state.error.message) + : String(this.state.error) + return ( +
+
+ Widget-Fehler +
+

+ {this.props.widgetId} +

+
+            {msg}
+          
+
+ ) + } + return this.props.children + } +} diff --git a/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx index 0d9e173..e8c11f0 100644 --- a/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx +++ b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx @@ -1,3 +1,5 @@ +import WidgetErrorBoundary from './WidgetErrorBoundary' + /** * @typedef {object} LayoutWidgetEntry * @property {string} id @@ -52,7 +54,11 @@ export function renderRegisteredWidget(id, ctx) { } const { Component } = spec const props = spec.mapProps ? spec.mapProps(ctx) : {} - return + return ( + + + + ) } /** diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index b7d634d..7c8ed71 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -41,7 +41,10 @@ export function ensurePilotLabWidgetsRegistered() { registerDashboardWidget({ id: 'activity_overview', Component: PilotActivitySection, - mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + mapProps: (ctx) => ({ + refreshTick: ctx.refreshTick, + chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), + }), }) }