diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index 63372a4..d0b30c4 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -1,7 +1,7 @@ """ Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout. -Single Source für erlaubte Widget-IDs (sync mit Frontend widgetRegistry). +Erlaubte Widget-IDs und Standard-Reihenfolge: widget_catalog.WIDGET_CATALOG """ from __future__ import annotations @@ -9,27 +9,22 @@ from typing import Any, Literal from pydantic import BaseModel, Field, model_validator -ALLOWED_WIDGET_IDS: frozenset[str] = frozenset( - { - "welcome", - "quick_capture", - "kpi_board", - "body_overview", - "activity_overview", - } -) +from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG + +# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul) +__all__ = [ + "ALLOWED_WIDGET_IDS", + "DashboardLayoutPayload", + "DashboardWidgetEntry", + "coalesce_effective_layout", + "default_layout_dict", +] def default_layout_dict() -> dict[str, Any]: return { "version": 1, - "widgets": [ - {"id": "welcome", "enabled": True}, - {"id": "quick_capture", "enabled": True}, - {"id": "kpi_board", "enabled": True}, - {"id": "body_overview", "enabled": True}, - {"id": "activity_overview", "enabled": True}, - ], + "widgets": [{"id": e["id"], "enabled": True} for e in WIDGET_CATALOG], } diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index 7124ed4..3598267 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -12,10 +12,18 @@ from auth import require_auth from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict from db import get_cursor, get_db from routers.profiles import get_pid +from widget_catalog import catalog_response router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"]) +@router.get("/widgets/catalog") +def get_widgets_catalog(session: dict = Depends(require_auth)) -> dict[str, Any]: + """Metadaten aller registrierbaren Dashboard-Widgets (IDs, Titel).""" + _ = session + return catalog_response() + + @router.get("/dashboard-layout") def get_dashboard_layout( x_profile_id: Optional[str] = Header(default=None), diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py new file mode 100644 index 0000000..25e186d --- /dev/null +++ b/backend/tests/test_widget_catalog.py @@ -0,0 +1,25 @@ +"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response).""" + +from dashboard_layout_schema import default_layout_dict +from widget_catalog import ALLOWED_WIDGET_IDS, WIDGET_CATALOG, catalog_response + + +def test_catalog_ids_unique_and_match_allowed(): + ids = [e["id"] for e in WIDGET_CATALOG] + assert len(ids) == len(set(ids)) + assert frozenset(ids) == ALLOWED_WIDGET_IDS + + +def test_default_layout_follows_catalog_order(): + d = default_layout_dict() + assert d["version"] == 1 + got = [w["id"] for w in d["widgets"]] + assert got == [e["id"] for e in WIDGET_CATALOG] + assert all(w["enabled"] is True for w in d["widgets"]) + + +def test_catalog_response_shape(): + r = catalog_response() + assert r["catalog_version"] == 1 + assert len(r["widgets"]) == len(WIDGET_CATALOG) + assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS diff --git a/backend/version.py b/backend/version.py index a54f0a8..20a4d2c 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.0.0", # Dashboard-Lab Layout API (/api/app) + "app_dashboard": "1.1.0", # Dashboard-Lab + GET /widgets/catalog (Widget-System Iteration 1) } CHANGELOG = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py new file mode 100644 index 0000000..210d398 --- /dev/null +++ b/backend/widget_catalog.py @@ -0,0 +1,55 @@ +""" +Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard). + +Single Source für: erlaubte IDs, Standard-Reihenfolge, Anzeige-Metadaten für API/GUI. +Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerPilotLabWidgets). +""" +from __future__ import annotations + +from typing import Any, TypedDict + + +class WidgetCatalogEntry(TypedDict): + id: str + title: str + description: str + + +# Reihenfolge in der Liste = Standard-Layout (alle default_enabled: True im Default-Layout) +WIDGET_CATALOG: list[WidgetCatalogEntry] = [ + { + "id": "welcome", + "title": "Willkommen", + "description": "Begrüßung und Kurzkontext", + }, + { + "id": "quick_capture", + "title": "Schnelleingabe", + "description": "Gewicht und Vitalwerte erfassen", + }, + { + "id": "kpi_board", + "title": "KPI-Kacheln", + "description": "Referenzwerte, KF%, Kalorien", + }, + { + "id": "body_overview", + "title": "Körper (Chart)", + "description": "Gewicht & Kennzahlen", + }, + { + "id": "activity_overview", + "title": "Aktivität", + "description": "Training & Konsistenz", + }, +] + +ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG) + + +def catalog_response() -> dict[str, Any]: + """Payload für GET /api/app/widgets/catalog.""" + return { + "catalog_version": 1, + "widgets": list(WIDGET_CATALOG), + } diff --git a/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js b/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js deleted file mode 100644 index 13db16f..0000000 --- a/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Dashboard-Lab: Widget-Metadaten (IDs müssen mit backend/dashboard_layout_schema.py übereinstimmen). - */ -export const DASHBOARD_LAB_WIDGET_META = [ - { id: 'welcome', label: 'Willkommen' }, - { id: 'quick_capture', label: 'Schnelleingabe' }, - { id: 'kpi_board', label: 'KPI-Kacheln' }, - { id: 'body_overview', label: 'Körper (Chart)' }, - { id: 'activity_overview', label: 'Aktivität' }, -] diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 18ce9fe..ac09de2 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -2,45 +2,34 @@ import { useCallback, useEffect, useState } from 'react' import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react' import { Link } from 'react-router-dom' import { api, formatFastApiDetail } from '../utils/api' -import { DASHBOARD_LAB_WIDGET_META } from '../app/areas/dashboardLab/dashboardLabWidgets' -import PilotWelcome from '../components/pilot/PilotWelcome' -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 { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry' +import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets' +import { moveWidget, toggleWidget } from '../widgetSystem/layoutEditor' -const metaById = Object.fromEntries(DASHBOARD_LAB_WIDGET_META.map((m) => [m.id, m])) - -function moveWidget(layout, index, delta) { - const next = [...layout.widgets] - const j = index + delta - if (j < 0 || j >= next.length) return layout - ;[next[index], next[j]] = [next[j], next[index]] - return { ...layout, widgets: next } -} - -function toggleWidget(layout, index) { - const next = layout.widgets.map((w, i) => - i === index ? { ...w, enabled: !w.enabled } : w - ) - const anyOn = next.some((w) => w.enabled) - if (!anyOn) return layout - return { ...layout, widgets: next } +function catalogMetaById(catalog) { + if (!catalog?.widgets?.length) return {} + return Object.fromEntries(catalog.widgets.map((w) => [w.id, w])) } export default function DashboardLabPage() { + ensurePilotLabWidgetsRegistered() + const [refreshTick, setRefreshTick] = useState(0) - const bump = () => setRefreshTick((t) => t + 1) + const requestRefresh = () => setRefreshTick((t) => t + 1) + const [catalog, setCatalog] = useState(null) const [bundle, setBundle] = useState(null) const [layout, setLayout] = useState(null) const [err, setErr] = useState(null) const [busy, setBusy] = useState(false) const [msg, setMsg] = useState(null) + const metaById = catalogMetaById(catalog) + const load = useCallback(async () => { setErr(null) try { - const b = await api.getAppDashboardLayout() + const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) + setCatalog(cat) setBundle(b) setLayout(b.layout) } catch (e) { @@ -92,23 +81,6 @@ export default function DashboardLabPage() { } } - const renderWidget = (id) => { - switch (id) { - case 'welcome': - return - case 'quick_capture': - return - case 'kpi_board': - return - case 'body_overview': - return - case 'activity_overview': - return - default: - return null - } - } - if (err && !layout) { return (
@@ -128,8 +100,6 @@ export default function DashboardLabPage() { ) } - const enabledOrder = layout.widgets.filter((w) => w.enabled) - return (
@@ -145,10 +115,10 @@ export default function DashboardLabPage() { App-Bereich: Dashboard-Lab

- Geschützte Route (Login erforderlich). Widget-Reihenfolge und Sichtbarkeit werden pro Profil in der - Datenbank gespeichert — getrennt vom Produktiv-Dashboard. Vergleich:{' '} + 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:{' '} - Pilot-Übersicht (festes Layout) + Pilot-Übersicht (festes Standard-Layout) .

@@ -171,15 +141,11 @@ export default function DashboardLabPage() { Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}

)} - {err && ( -

{err}

- )} - {msg && ( -

{msg}

- )} + {err &&

{err}

} + {msg &&

{msg}

}
    {layout.widgets.map((w, i) => { - const label = metaById[w.id]?.label || w.id + const label = metaById[w.id]?.title || w.id return (
- {enabledOrder.map((w) => renderWidget(w.id))} +
) } diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx index a657ead..a45351b 100644 --- a/frontend/src/pages/PilotVizPage.jsx +++ b/frontend/src/pages/PilotVizPage.jsx @@ -1,21 +1,19 @@ import { useState } from 'react' import { FlaskConical } from 'lucide-react' import { Link } from 'react-router-dom' -import PilotWelcome from '../components/pilot/PilotWelcome' -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 { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry' +import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets' +import { DEFAULT_LAB_LAYOUT } from '../widgetSystem/defaultLabLayout' /** - * Pilot-Übersicht nach Product-Spec: - * Willkommen → Schnelleingabe (Gewicht + Vitalwerte) → KPIs (Referenzen + KF% + Ø kcal, max. 9) - * → Bereich Körper (Gewicht-Chart 30 T, Ø7/Ø14, Bewertung wie Verlauf) - * → Bereich Aktivität (Trainingstyp 30 T, Konsistenz). + * Pilot-Übersicht nach Product-Spec (festes Standard-Layout). + * Nutzt dasselbe Widget-Rendering wie /app/dashboard-lab. */ export default function PilotVizPage() { + ensurePilotLabWidgetsRegistered() + const [refreshTick, setRefreshTick] = useState(0) - const bump = () => setRefreshTick((t) => t + 1) + const requestRefresh = () => setRefreshTick((t) => t + 1) return (
@@ -37,11 +35,11 @@ export default function PilotVizPage() {

- - - - - +
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 815ae7d..ad04e3b 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -70,7 +70,8 @@ export const api = { getProfile: () => req('/profile'), updateActiveProfile:(d)=> req('/profile', jput(d)), - // App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + // App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + Widget-Katalog + getAppWidgetsCatalog: () => req('/app/widgets/catalog'), getAppDashboardLayout: () => req('/app/dashboard-layout'), putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)), resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }), diff --git a/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx new file mode 100644 index 0000000..edb7ead --- /dev/null +++ b/frontend/src/widgetSystem/dashboardWidgetRegistry.jsx @@ -0,0 +1,60 @@ +/** @typedef {{ refreshTick: number, requestRefresh: () => void }} WidgetRenderContext */ + +const registry = new Map() + +/** + * @param {{ id: string, Component: import('react').ComponentType, mapProps?: (ctx: WidgetRenderContext) => Record }} spec + */ +export function registerDashboardWidget(spec) { + if (!spec?.id || !spec?.Component) { + console.warn('registerDashboardWidget: id und Component erforderlich', spec) + return + } + registry.set(spec.id, spec) +} + +export function getRegisteredWidgetIds() { + return [...registry.keys()] +} + +export function clearDashboardWidgetRegistry() { + registry.clear() +} + +/** + * Nur für Tests: Registry neu füllen. + */ +export function __resetDashboardWidgetRegistryForTests() { + registry.clear() +} + +/** + * @param {string} id + * @param {WidgetRenderContext} ctx + */ +export function renderRegisteredWidget(id, ctx) { + const spec = registry.get(id) + if (!spec) { + return ( +
+ Unbekanntes Widget +
{id}
+
+ ) + } + const { Component } = spec + const props = spec.mapProps ? spec.mapProps(ctx) : {} + return +} + +/** + * Rendert alle aktivierten Widgets in Layout-Reihenfolge. + * @param {{ version: number, widgets: Array<{ id: string, enabled: boolean }> }} layout + * @param {WidgetRenderContext} ctx + */ +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))} +} diff --git a/frontend/src/widgetSystem/defaultLabLayout.js b/frontend/src/widgetSystem/defaultLabLayout.js new file mode 100644 index 0000000..5da9c5c --- /dev/null +++ b/frontend/src/widgetSystem/defaultLabLayout.js @@ -0,0 +1,15 @@ +/** + * Standard-Layout v1 (nur Pilot-Fallback ohne API). + * Reihenfolge: gleich backend/widget_catalog.WIDGET_CATALOG bei Änderung dort mitpflegen + * (oder später nur noch aus GET /api/app/dashboard-layout default_layout beziehen). + */ +export const DEFAULT_LAB_LAYOUT = { + version: 1, + widgets: [ + { id: 'welcome', enabled: true }, + { id: 'quick_capture', enabled: true }, + { id: 'kpi_board', enabled: true }, + { id: 'body_overview', enabled: true }, + { id: 'activity_overview', enabled: true }, + ], +} diff --git a/frontend/src/widgetSystem/layoutEditor.js b/frontend/src/widgetSystem/layoutEditor.js new file mode 100644 index 0000000..4737714 --- /dev/null +++ b/frontend/src/widgetSystem/layoutEditor.js @@ -0,0 +1,16 @@ +export function moveWidget(layout, index, delta) { + const next = [...layout.widgets] + const j = index + delta + if (j < 0 || j >= next.length) return layout + const t = next[index] + next[index] = next[j] + next[j] = t + return { ...layout, widgets: next } +} + +export function toggleWidget(layout, index) { + const next = layout.widgets.map((w, i) => (i === index ? { ...w, enabled: !w.enabled } : w)) + const anyOn = next.some((w) => w.enabled) + if (!anyOn) return layout + return { ...layout, widgets: next } +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js new file mode 100644 index 0000000..5d21cdc --- /dev/null +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -0,0 +1,47 @@ +/** + * Pilot/Lab-Widgets registrieren. IDs müssen zu backend/widget_catalog.WIDGET_CATALOG passen. + */ +import PilotWelcome from '../components/pilot/PilotWelcome' +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 { registerDashboardWidget } from './dashboardWidgetRegistry' + +let _registered = false + +export function ensurePilotLabWidgetsRegistered() { + if (_registered) return + _registered = true + + registerDashboardWidget({ + id: 'welcome', + Component: PilotWelcome, + mapProps: () => ({}), + }) + registerDashboardWidget({ + id: 'quick_capture', + Component: PilotQuickCapture, + mapProps: (ctx) => ({ onSaved: ctx.requestRefresh }), + }) + registerDashboardWidget({ + id: 'kpi_board', + Component: PilotKpiBoard, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'body_overview', + Component: PilotBodySection, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) + registerDashboardWidget({ + id: 'activity_overview', + Component: PilotActivitySection, + mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }), + }) +} + +/** @internal Nur für Tests */ +export function __resetPilotLabRegistrationForTests() { + _registered = false +}