From e5f6e6c10dc7b0d68481ff52041da3e29118b368 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 7 Apr 2026 11:38:35 +0200
Subject: [PATCH] feat: Integrate Dashboard-Lab layout and enhance settings
navigation
- Added new routes and API endpoints for the Dashboard-Lab layout in the app.
- Updated main.py to include the app_dashboard router for backend integration.
- Enhanced App.jsx to include a route for the DashboardLabPage.
- Modified SettingsPage to add a link to the new Dashboard-Lab layout, improving user access to dashboard features.
- Updated version.py to reflect the new app_dashboard module version.
---
backend/dashboard_layout_schema.py | 90 +++++++
backend/main.py | 2 +
backend/migrations/039_dashboard_layout.sql | 5 +
backend/routers/app_dashboard.py | 81 ++++++
backend/tests/test_dashboard_layout_schema.py | 56 ++++
backend/version.py | 3 +-
frontend/src/App.jsx | 2 +
.../areas/dashboardLab/dashboardLabWidgets.js | 10 +
frontend/src/pages/DashboardLabPage.jsx | 243 ++++++++++++++++++
frontend/src/pages/SettingsPage.jsx | 34 ++-
frontend/src/utils/api.js | 5 +
11 files changed, 522 insertions(+), 9 deletions(-)
create mode 100644 backend/dashboard_layout_schema.py
create mode 100644 backend/migrations/039_dashboard_layout.sql
create mode 100644 backend/routers/app_dashboard.py
create mode 100644 backend/tests/test_dashboard_layout_schema.py
create mode 100644 frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js
create mode 100644 frontend/src/pages/DashboardLabPage.jsx
diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py
new file mode 100644
index 0000000..63372a4
--- /dev/null
+++ b/backend/dashboard_layout_schema.py
@@ -0,0 +1,90 @@
+"""
+Dashboard-Layout v1 (Nutzer-Lab): Validierung und Standard-Layout.
+
+Single Source für erlaubte Widget-IDs (sync mit Frontend widgetRegistry).
+"""
+from __future__ import annotations
+
+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",
+ }
+)
+
+
+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},
+ ],
+ }
+
+
+class DashboardWidgetEntry(BaseModel):
+ id: str = Field(min_length=1, max_length=64)
+ enabled: bool = True
+
+
+class DashboardLayoutPayload(BaseModel):
+ version: Literal[1] = 1
+ widgets: list[DashboardWidgetEntry] = Field(min_length=1, max_length=32)
+
+ @model_validator(mode="after")
+ def _validate_widgets(self) -> DashboardLayoutPayload:
+ ids = [w.id for w in self.widgets]
+ if len(ids) != len(set(ids)):
+ raise ValueError("Doppelte widget id")
+ bad = [i for i in ids if i not in ALLOWED_WIDGET_IDS]
+ if bad:
+ raise ValueError(f"Unbekannte Widget-IDs: {bad}")
+ if not any(w.enabled for w in self.widgets):
+ raise ValueError("Mindestens ein Widget muss aktiv sein")
+ 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],
+ }
+
+
+def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]:
+ """
+ Returns (has_custom, effective_layout).
+ has_custom=True nur wenn DB-Wert vorhanden und gültig (v1).
+ """
+ if raw is None:
+ return False, default_layout_dict()
+ parsed_obj: Any = raw
+ if isinstance(raw, str):
+ import json
+
+ try:
+ parsed_obj = json.loads(raw)
+ except json.JSONDecodeError:
+ return False, default_layout_dict()
+ if not isinstance(parsed_obj, dict):
+ return False, default_layout_dict()
+ try:
+ parsed = DashboardLayoutPayload.model_validate(
+ {
+ "version": parsed_obj.get("version", 1),
+ "widgets": parsed_obj.get("widgets", []),
+ }
+ )
+ return True, parsed.to_stored_dict()
+ except Exception:
+ return False, default_layout_dict()
diff --git a/backend/main.py b/backend/main.py
index 850d589..5ad6444 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -30,6 +30,7 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
+from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@@ -119,6 +120,7 @@ app.include_router(workflow_questions.router) # /api/workflow/questions/* (Pha
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
+app.include_router(app_dashboard.router) # /api/app/dashboard-layout
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")
diff --git a/backend/migrations/039_dashboard_layout.sql b/backend/migrations/039_dashboard_layout.sql
new file mode 100644
index 0000000..5dc6772
--- /dev/null
+++ b/backend/migrations/039_dashboard_layout.sql
@@ -0,0 +1,5 @@
+-- Nutzer-Dashboard: Layout (JSONB) für geschützten App-Lab-Bereich (Issue #65, Phase 1)
+ALTER TABLE profiles ADD COLUMN IF NOT EXISTS dashboard_layout JSONB DEFAULT NULL;
+
+COMMENT ON COLUMN profiles.dashboard_layout IS
+ 'Optional: konfigurierbare Dashboard-Widget-Reihenfolge/Sichtbarkeit (v1 JSON). NULL = Standard.';
diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py
new file mode 100644
index 0000000..7124ed4
--- /dev/null
+++ b/backend/routers/app_dashboard.py
@@ -0,0 +1,81 @@
+"""
+Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard).
+
+/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id).
+"""
+from typing import Any, Optional
+
+from fastapi import APIRouter, Depends, Header, HTTPException
+from psycopg2.extras import Json
+
+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
+
+router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
+
+
+@router.get("/dashboard-layout")
+def get_dashboard_layout(
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+) -> dict[str, Any]:
+ _ = session
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT dashboard_layout FROM profiles WHERE id = %s",
+ (pid,),
+ )
+ row = cur.fetchone()
+ raw = row["dashboard_layout"] if row else None
+ custom, effective = coalesce_effective_layout(raw)
+ return {
+ "custom": custom,
+ "layout": effective,
+ "default_layout": default_layout_dict(),
+ }
+
+
+@router.put("/dashboard-layout")
+def put_dashboard_layout(
+ body: dict[str, Any],
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+) -> dict[str, Any]:
+ _ = session
+ pid = get_pid(x_profile_id)
+ try:
+ payload = DashboardLayoutPayload.model_validate(body)
+ except Exception as e:
+ raise HTTPException(422, str(e)) from e
+ stored = payload.to_stored_dict()
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "UPDATE profiles SET dashboard_layout = %s WHERE id = %s",
+ (Json(stored), pid),
+ )
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Profil nicht gefunden")
+ return {"ok": True, "layout": stored}
+
+
+@router.post("/dashboard-layout/reset")
+def reset_dashboard_layout(
+ x_profile_id: Optional[str] = Header(default=None),
+ session: dict = Depends(require_auth),
+) -> dict[str, Any]:
+ _ = session
+ pid = get_pid(x_profile_id)
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "UPDATE profiles SET dashboard_layout = NULL WHERE id = %s",
+ (pid,),
+ )
+ if cur.rowcount == 0:
+ raise HTTPException(404, "Profil nicht gefunden")
+ return {"ok": True, "layout": default_layout_dict()}
diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py
new file mode 100644
index 0000000..e030adf
--- /dev/null
+++ b/backend/tests/test_dashboard_layout_schema.py
@@ -0,0 +1,56 @@
+import pytest
+
+from dashboard_layout_schema import (
+ ALLOWED_WIDGET_IDS,
+ DashboardLayoutPayload,
+ coalesce_effective_layout,
+ default_layout_dict,
+)
+
+
+def test_default_has_all_allowed_ids():
+ d = default_layout_dict()
+ got = {w["id"] for w in d["widgets"]}
+ assert got == ALLOWED_WIDGET_IDS
+
+
+def test_payload_rejects_duplicate_ids():
+ with pytest.raises(Exception):
+ DashboardLayoutPayload.model_validate(
+ {
+ "version": 1,
+ "widgets": [
+ {"id": "welcome", "enabled": True},
+ {"id": "welcome", "enabled": False},
+ ],
+ }
+ )
+
+
+def test_payload_requires_one_enabled():
+ with pytest.raises(Exception):
+ DashboardLayoutPayload.model_validate(
+ {
+ "version": 1,
+ "widgets": [{"id": "welcome", "enabled": False}],
+ }
+ )
+
+
+def test_coalesce_none():
+ custom, eff = coalesce_effective_layout(None)
+ assert custom is False
+ assert eff == default_layout_dict()
+
+
+def test_coalesce_valid_raw():
+ raw = {
+ "version": 1,
+ "widgets": [
+ {"id": "welcome", "enabled": True},
+ {"id": "kpi_board", "enabled": True},
+ ],
+ }
+ custom, eff = coalesce_effective_layout(raw)
+ assert custom is True
+ assert eff == raw
diff --git a/backend/version.py b/backend/version.py
index 47d2c3d..a54f0a8 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9n"
BUILD_DATE = "2026-04-05"
-DB_SCHEMA_VERSION = "20260406b" # Migration 038
+DB_SCHEMA_VERSION = "20260406c" # Migration 039
MODULE_VERSIONS = {
"auth": "1.2.0",
@@ -30,6 +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)
}
CHANGELOG = [
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index cd4f495..c7ec039 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -25,6 +25,7 @@ import SettingsPage from './pages/SettingsPage'
import SettingsShell from './layouts/SettingsShell'
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
import PilotVizPage from './pages/PilotVizPage'
+import DashboardLabPage from './pages/DashboardLabPage'
import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage'
@@ -255,6 +256,7 @@ function AppShell() {
}/>
}/>
} />
+ } />
diff --git a/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js b/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js
new file mode 100644
index 0000000..13db16f
--- /dev/null
+++ b/frontend/src/app/areas/dashboardLab/dashboardLabWidgets.js
@@ -0,0 +1,10 @@
+/**
+ * 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
new file mode 100644
index 0000000..18ce9fe
--- /dev/null
+++ b/frontend/src/pages/DashboardLabPage.jsx
@@ -0,0 +1,243 @@
+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'
+
+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 }
+}
+
+export default function DashboardLabPage() {
+ const [refreshTick, setRefreshTick] = useState(0)
+ const bump = () => setRefreshTick((t) => t + 1)
+ 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 load = useCallback(async () => {
+ setErr(null)
+ try {
+ const b = await api.getAppDashboardLayout()
+ setBundle(b)
+ setLayout(b.layout)
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const save = async () => {
+ if (!layout) return
+ setBusy(true)
+ setMsg(null)
+ setErr(null)
+ try {
+ await api.putAppDashboardLayout(layout)
+ setMsg('Layout gespeichert.')
+ await load()
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const reset = async () => {
+ if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return
+ setBusy(true)
+ setMsg(null)
+ setErr(null)
+ try {
+ const r = await api.resetAppDashboardLayout()
+ setLayout(r.layout)
+ setMsg('Auf Standard zurückgesetzt.')
+ await load()
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const applyDefaultLocal = () => {
+ if (bundle?.default_layout) {
+ setLayout(structuredClone(bundle.default_layout))
+ setMsg('Standard geladen (noch nicht gespeichert).')
+ }
+ }
+
+ 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 (
+
+
{err}
+
+
+ )
+ }
+
+ if (!layout) {
+ return (
+
+ )
+ }
+
+ const enabledOrder = layout.widgets.filter((w) => w.enabled)
+
+ return (
+
+
+
+ ← Einstellungen
+
+
+
+ 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:{' '}
+
+ Pilot-Übersicht (festes Layout)
+
+ .
+
+
+
+
+
+ Layout (v1)
+
+ {bundle && (
+
+ Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
+
+ )}
+ {err && (
+
{err}
+ )}
+ {msg && (
+
{msg}
+ )}
+
+
+
+
+
+
+
+
+ {enabledOrder.map((w) => renderWidget(w.id))}
+
+ )
+}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index 887db25..5932df9 100644
--- a/frontend/src/pages/SettingsPage.jsx
+++ b/frontend/src/pages/SettingsPage.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target } from 'lucide-react'
+import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
@@ -452,13 +452,31 @@ export default function SettingsPage() {
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
-
- Pilot öffnen
-
+
+
+ Pilot öffnen
+
+
+
+ Dashboard-Lab (Layout API)
+
+
{/* Auth actions */}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 3cb7659..815ae7d 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -70,6 +70,11 @@ export const api = {
getProfile: () => req('/profile'),
updateActiveProfile:(d)=> req('/profile', jput(d)),
+ // App-Bereich: Dashboard-Lab (Layout JSON, Issue #65)
+ getAppDashboardLayout: () => req('/app/dashboard-layout'),
+ putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)),
+ resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }),
+
// Persönliche Referenzwerte (Profil, historisch)
listReferenceValueTypes: () => req('/reference-value-types'),
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),