diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py
index de3fced..b7b75f5 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.
+Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
-Erlaubte Widget-IDs und Standard-Reihenfolge: widget_catalog.WIDGET_CATALOG
+Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
"""
from __future__ import annotations
@@ -10,7 +10,12 @@ from typing import Any, Literal
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, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
+from widget_catalog import (
+ ALLOWED_WIDGET_IDS,
+ DEFAULT_LAB_WIDGET_IDS,
+ DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS,
+ WIDGET_CATALOG,
+)
# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
__all__ = [
@@ -19,10 +24,13 @@ __all__ = [
"DashboardWidgetEntry",
"coalesce_effective_layout",
"default_layout_dict",
+ "lab_default_layout_dict",
+ "product_default_layout_dict",
]
-def default_layout_dict() -> dict[str, Any]:
+def lab_default_layout_dict() -> dict[str, Any]:
+ """Standard für Dashboard-Lab (Experimentier-Widgets)."""
on = DEFAULT_LAB_WIDGET_IDS
return {
"version": 1,
@@ -30,6 +38,20 @@ def default_layout_dict() -> dict[str, Any]:
}
+def product_default_layout_dict() -> dict[str, Any]:
+ """System-Standard für die Produkt-Übersicht (kein DB-Override)."""
+ on = DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
+ return {
+ "version": 1,
+ "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG],
+ }
+
+
+def default_layout_dict() -> dict[str, Any]:
+ """Alias: Produkt-Standard (coalesce, Reset). Lab nutzt lab_default_layout_dict()."""
+ return product_default_layout_dict()
+
+
class DashboardWidgetEntry(BaseModel):
id: str = Field(min_length=1, max_length=64)
enabled: bool = True
diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py
index a9f9c08..19b489e 100644
--- a/backend/routers/app_dashboard.py
+++ b/backend/routers/app_dashboard.py
@@ -9,7 +9,12 @@ 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 dashboard_layout_schema import (
+ DashboardLayoutPayload,
+ coalesce_effective_layout,
+ lab_default_layout_dict,
+ product_default_layout_dict,
+)
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
from db import get_cursor, get_db
from routers.profiles import get_pid
@@ -47,11 +52,13 @@ def get_dashboard_layout(
custom, effective = coalesce_effective_layout(raw)
with get_db() as conn:
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
- default_adj = apply_entitlements_to_layout_dict(default_layout_dict(), pid, conn)
+ product_adj = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn)
+ lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)
return {
"custom": custom,
"layout": effective,
- "default_layout": default_adj,
+ "product_default_layout": product_adj,
+ "lab_default_layout": lab_adj,
}
@@ -100,4 +107,5 @@ def reset_dashboard_layout(
)
if cur.rowcount == 0:
raise HTTPException(404, "Profil nicht gefunden")
- return {"ok": True, "layout": default_layout_dict()}
+ cleared = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn)
+ return {"ok": True, "layout": cleared}
diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py
index e030adf..60bc7ac 100644
--- a/backend/tests/test_dashboard_layout_schema.py
+++ b/backend/tests/test_dashboard_layout_schema.py
@@ -6,12 +6,14 @@ from dashboard_layout_schema import (
coalesce_effective_layout,
default_layout_dict,
)
+from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
def test_default_has_all_allowed_ids():
d = default_layout_dict()
got = {w["id"] for w in d["widgets"]}
assert got == ALLOWED_WIDGET_IDS
+ assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
def test_payload_rejects_duplicate_ids():
@@ -32,7 +34,7 @@ def test_payload_requires_one_enabled():
DashboardLayoutPayload.model_validate(
{
"version": 1,
- "widgets": [{"id": "welcome", "enabled": False}],
+ "widgets": [{"id": "dashboard_greeting", "enabled": False}],
}
)
diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py
index 0ee34a8..478ead4 100644
--- a/backend/tests/test_widget_catalog.py
+++ b/backend/tests/test_widget_catalog.py
@@ -2,7 +2,12 @@
from dashboard_layout_schema import default_layout_dict
from dashboard_widget_entitlements import widgets_catalog_payload
-from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
+from widget_catalog import (
+ ALLOWED_WIDGET_IDS,
+ DEFAULT_LAB_WIDGET_IDS,
+ DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS,
+ WIDGET_CATALOG,
+)
def test_catalog_ids_unique_and_match_allowed():
@@ -17,10 +22,17 @@ def test_default_layout_follows_catalog_order():
got = [w["id"] for w in d["widgets"]]
assert got == [e["id"] for e in WIDGET_CATALOG]
enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]}
- assert enabled_ids == DEFAULT_LAB_WIDGET_IDS
+ assert enabled_ids == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
assert any(w["enabled"] for w in d["widgets"])
+def test_lab_default_matches_lab_widget_ids():
+ from dashboard_layout_schema import lab_default_layout_dict
+
+ d = lab_default_layout_dict()
+ assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_LAB_WIDGET_IDS
+
+
def test_catalog_payload_shape(monkeypatch):
monkeypatch.setattr(
"dashboard_widget_entitlements._check_feature_access",
diff --git a/backend/version.py b/backend/version.py
index 8f5f3e9..1e8c235 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.8.0", # widget catalog allowed via features; layout entitlements on GET/PUT
+ "app_dashboard": "1.9.0", # product vs lab layout defaults; user dashboard configure page API fields
}
CHANGELOG = [
diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py
index af755d9..f89a908 100644
--- a/backend/widget_catalog.py
+++ b/backend/widget_catalog.py
@@ -133,4 +133,21 @@ DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
}
)
+# Produkt-Übersicht (/): Default wenn Nutzer kein dashboard_layout in der DB hat (Physisch: nur Profil-JSON).
+DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS: frozenset[str] = frozenset(
+ {
+ "dashboard_greeting",
+ "quick_weight_today",
+ "body_stat_strip",
+ "status_pills",
+ "trend_kcal_weight",
+ "nutrition_activity_summary",
+ "activity_overview",
+ "recovery_sleep_rest",
+ "goals_focus_teaser",
+ "profile_goals_progress",
+ "ai_pipeline_insight",
+ }
+)
+
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index c7ec039..d02d77c 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -26,6 +26,7 @@ import SettingsShell from './layouts/SettingsShell'
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
import PilotVizPage from './pages/PilotVizPage'
import DashboardLabPage from './pages/DashboardLabPage'
+import DashboardConfigurePage from './pages/DashboardConfigurePage'
import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage'
@@ -232,6 +233,7 @@ function AppShell() {
}>
} />
} />
+ } />
}>
}>
diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js
index 4e769c1..f03cc3f 100644
--- a/frontend/src/config/settingsNav.js
+++ b/frontend/src/config/settingsNav.js
@@ -5,5 +5,6 @@
export const SETTINGS_SHELL_NAV_ITEMS = [
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
+ { id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
]
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index 9a7987c..c8d9d2d 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -1,82 +1,56 @@
-import { useState, useEffect } from 'react'
-import { useNavigate, useLocation } from 'react-router-dom'
-import { Brain } from 'lucide-react'
+import { useEffect, useMemo, useState } from 'react'
+import { Link, useNavigate, useLocation } from 'react-router-dom'
+import { LayoutDashboard } from 'lucide-react'
import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext'
-import { getBfCategory } from '../utils/calc'
import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
-import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
-import SleepWidget from '../components/SleepWidget'
-import RestDaysWidget from '../components/RestDaysWidget'
-import Markdown from '../utils/Markdown'
-import QuickWeightEntry from '../components/QuickWeightEntry'
-import TrendKcalWeightChart from '../components/TrendKcalWeightChart'
-import { Pill, StatCard } from '../components/DashboardStatKit'
-import dayjs from 'dayjs'
-import 'dayjs/locale/de'
-import DashboardSection from '../components/DashboardSection'
-import DashboardTile from '../components/DashboardTile'
-import {
- DASHBOARD_TILE_GRID_COLS,
- dashboardStatGridClassName,
- dashboardTileGridClassName
-} from '../utils/dashboardLayout'
-dayjs.locale('de')
+import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
+import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
+
+function catalogMetaById(catalog) {
+ if (!catalog?.widgets?.length) return {}
+ return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
+}
-// ── Main Dashboard ────────────────────────────────────────────────────────────
export default function Dashboard() {
const nav = useNavigate()
const location = useLocation()
const { activeProfile } = useProfile()
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
- const [goalsCount, setGoalsCount] = useState(null)
+ const [layoutBundle, setLayoutBundle] = useState(null)
+ const [catalog, setCatalog] = useState(null)
+ const [layoutLoading, setLayoutLoading] = useState(true)
+ const [refreshTick, setRefreshTick] = useState(0)
- const [stats, setStats] = useState(null)
- const [weights, setWeights] = useState([])
- const [calipers, setCalipers] = useState([])
- const [circs, setCircs] = useState([])
- const [nutrition, setNutrition] = useState([])
- const [activities,setActivities]= useState([])
- const [insights, setInsights] = useState([])
- const [loading, setLoading] = useState(true)
- const [showInsight, setShowInsight] = useState(false)
- const [pipelineLoading, setPipelineLoading] = useState(false)
- const [pipelineError, setPipelineError] = useState(null)
+ const requestRefresh = () => setRefreshTick((t) => t + 1)
- const load = () => Promise.all([
- api.getStats(),
- api.listWeight(60),
- api.listCaliper(3),
- api.listCirc(2),
- api.listNutrition(30),
- api.listActivity(800, 30),
- api.latestInsights(),
- ]).then(([s,w,ca,ci,n,a,ins])=>{
- setStats(s); setWeights(w); setCalipers(ca); setCircs(ci)
- setNutrition(n); setActivities(a)
- setInsights(Array.isArray(ins)?ins:[])
- setLoading(false)
- }).catch(err => {
- console.error('Dashboard load failed:', err)
- // Set empty data on error so UI can still render
- setStats(null); setWeights([]); setCalipers([]); setCircs([])
- setNutrition([]); setActivities([]); setInsights([])
- setLoading(false)
- })
+ useEffect(() => {
+ ensurePilotLabWidgetsRegistered()
+ }, [])
- const runPipeline = async () => {
- setPipelineLoading(true); setPipelineError(null)
- try {
- await api.insightPipeline()
- await load()
- } catch(e) {
- setPipelineError('Fehler: '+e.message)
- } finally { setPipelineLoading(false) }
- }
-
- useEffect(()=>{ load() },[])
+ useEffect(() => {
+ let cancel = false
+ setLayoutLoading(true)
+ Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
+ .then(([b, c]) => {
+ if (cancel) return
+ setLayoutBundle(b)
+ setCatalog(c)
+ })
+ .catch(() => {
+ if (cancel) return
+ setLayoutBundle(null)
+ setCatalog(null)
+ })
+ .finally(() => {
+ if (!cancel) setLayoutLoading(false)
+ })
+ return () => {
+ cancel = true
+ }
+ }, [])
useEffect(() => {
if (!location.state?.adminDenied) return
@@ -86,60 +60,19 @@ export default function Dashboard() {
return () => window.clearTimeout(clear)
}, [location.state, nav])
- useEffect(() => {
- if (!activeProfile?.id) return
- api.listGoals()
- .then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
- .catch(() => setGoalsCount(null))
- }, [activeProfile?.id])
+ const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
- if (loading) return
-
- const latestCal = calipers[0]
- const latestCir = circs[0]
- const latestW = weights[0]
- const prevW = weights[1]
- const sex = activeProfile?.sex||'m'
- const height = activeProfile?.height||178
-
- // Deltas
- const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null
- const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct,sex) : null
- const bfPrev = calipers[1]?.body_fat_pct
- const bfDelta = latestCal?.body_fat_pct&&bfPrev ? Math.round((latestCal.body_fat_pct-bfPrev)*10)/10 : null
-
- // WHR / WHtR
- const whr = latestCir?.c_waist&&latestCir?.c_hip ? Math.round(latestCir.c_waist/latestCir.c_hip*100)/100 : null
- const whtr = latestCir?.c_waist&&height ? Math.round(latestCir.c_waist/height*100)/100 : null
-
- // Nutrition averages (last 7 days)
- const recentNutr = nutrition.filter(n=>n.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
- const avgKcal = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.kcal||0),0)/recentNutr.length) : null
- const avgProtein = recentNutr.length ? Math.round(recentNutr.reduce((s,n)=>s+(n.protein_g||0),0)/recentNutr.length*10)/10 : null
- const ptLow = Math.round((latestW?.weight||80)*1.6)
- const proteinOk = avgProtein && avgProtein >= ptLow
-
- // Activity (last 7 days)
- const recentAct = activities.filter(a=>a.date>=dayjs().subtract(7,'day').format('YYYY-MM-DD'))
- const actKcal = recentAct.length ? Math.round(recentAct.reduce((s,a)=>s+(a.kcal_active||0),0)) : null
-
- // Status pills
- const pills = []
- if (whr) pills.push({label:'WHR', value:whr, status:whr<(sex==='m'?0.90:0.85)?'good':'warn', sub:`<${sex==='m'?'0,90':'0,85'}`})
- if (whtr) pills.push({label:'WHtR', value:whtr, status:whtr<0.5?'good':'warn', sub:'<0,50'})
- if (avgProtein) pills.push({label:'Protein Ø7T', value:avgProtein+'g', status:proteinOk?'good':'warn', sub:`Ziel ${ptLow}g`})
- if (bfCat) pills.push({label:'KF', value:latestCal.body_fat_pct+'%', status:latestCal.body_fat_pct<(sex==='m'?18:25)?'good':'warn', sub:bfCat.label})
-
- // Latest overall insight
- const latestInsight = insights.find(i=>i.scope==='gesamt')||insights[0]
-
- const hasAnyData = latestW||latestCal||nutrition.length>0
-
- const showNutrSummary = !!(avgKcal || avgProtein)
- const showActSummary = actKcal != null
- const summaryBoth = showNutrSummary && showActSummary
- const summarySpanM = summaryBoth ? 1 : 2
- const summarySpanD = summaryBoth ? 2 : 4
+ const layoutForPreview = useMemo(() => {
+ if (!layoutBundle?.layout) return null
+ const L = layoutBundle.layout
+ return {
+ ...L,
+ widgets: L.widgets.map((w) => ({
+ ...w,
+ enabled: w.enabled && metaById[w.id]?.allowed !== false,
+ })),
+ }
+ }, [layoutBundle, metaById])
return (
@@ -157,296 +90,41 @@ export default function Dashboard() {
lineHeight: 1.5,
}}
>
- Kein Admin-Zugriff. Dieser Bereich ist nur für Konten mit Administrator-Rolle.
- Du wurdest zur Übersicht weitergeleitet.
+ Kein Admin-Zugriff. Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
+ Übersicht weitergeleitet.
)}
- {/* Header greeting */}
-
-
- Hallo, {activeProfile?.name||'Nutzer'} 👋
-
-
- {dayjs().format('dddd, DD. MMMM YYYY')}
- {latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
-
+
+
+
+
+ Übersicht anpassen
+
- {/* Email Verification Banner */}
- {activeProfile &&
}
+ {activeProfile && }
+ {activeProfile && }
- {/* Trial Banner */}
- {activeProfile && }
-
- {!hasAnyData && (
+ {layoutLoading && (
-
Willkommen bei Mitai Jinkendo!
-
Starte mit deiner ersten Messung.
-
nav('/capture')}>
- Erfassen starten
-
+
)}
- {hasAnyData && <>
- nav('/weight')}>
- Alle Einträge →
-
- }
- >
-
-
-
-
-
-
-
- nav('/history')} color="#378ADD"/>
- {latestCal?.body_fat_pct && nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
- {latestCal?.lean_mass && nav('/history',{state:{tab:'body'}})}/>}
- {avgKcal && nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
-
- {pills.length > 0 && (
-
- )}
-
-
- {(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
-
-
- {activeProfile?.goal_weight && latestW && (()=>{
- const start = Math.max(...weights.map(w=>w.weight))
- const curr = latestW.weight
- const goal = activeProfile.goal_weight
- const total = start - goal
- const done = start - curr
- const pct = total > 0 ? Math.min(100, Math.round(done/total*100)) : 100
- const remain = Math.round((curr-goal)*10)/10
- return (
-
-
- Gewicht: {curr} → {goal} kg
- {remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'}
-
-
-
{pct}% des Weges
-
- )
- })()}
- {activeProfile?.goal_bf_pct && latestCal?.body_fat_pct && (()=>{
- const curr = latestCal.body_fat_pct
- const goal = activeProfile.goal_bf_pct
- const remain= Math.round((curr-goal)*10)/10
- const pct = curr<=goal ? 100 : Math.min(100,Math.round((1-(curr-goal)/Math.max(curr-goal,5))*100))
- return (
-
-
- Körperfett: {curr}% → {goal}%
- {remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'}
-
-
-
-
-
Aktuell: {bfCat?.label}
-
- )
- })()}
-
-
- )}
-
- {(weights.length>2||nutrition.length>2) && (
-
nav('/history',{state:{tab:'body'}})}>
- Details →
-
- }
- >
-
-
-
-
- Ø Kalorien
- Gewicht
-
-
-
-
- )}
-
- {(showNutrSummary || showActSummary) && (
-
-
- {showNutrSummary && (
-
- nav('/history',{state:{tab:'nutrition'}})}>
-
🍽️ ERNÄHRUNG (Ø 7T)
- {avgKcal &&
{avgKcal} kcal
}
- {avgProtein &&
- {avgProtein}g Protein {proteinOk?'✓':'⚠️'}
-
}
-
→ Verlauf Ernährung
-
-
- )}
- {showActSummary && (
-
- nav('/history',{state:{tab:'activity'}})}>
-
🏋️ AKTIVITÄT (7T)
-
{actKcal} kcal
-
{recentAct.length} Trainings
-
→ Verlauf Aktivität
-
-
- )}
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
- {activities.length > 0 && (
-
nav('/activity')}>
- Details →
-
- }
- >
-
-
-
-
-
-
- )}
-
-
{ e.stopPropagation(); nav('/goals') }}>
- Ziele bearbeiten →
-
- }
- >
-
- nav('/goals')}>
- {goalsCount != null && (
-
- {goalsCount === 0
- ? 'Noch keine Ziele angelegt.'
- : `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
-
- )}
-
- Hier pflegst du Focus Areas, Meilensteine und Fortschritt – unabhängig von der KI-Analyse-Seite.
- Tippen zum Öffnen oder unten in der Navigation Ziele wählen.
-
-
-
-
-
-
nav('/analysis')}>
- Analysen →
-
- }
- >
-
-
-
- {pipelineLoading
- ? <> Analyse läuft… (3 Stufen)>
- : <>
🔬 Mehrstufige Analyse starten>}
-
- {pipelineError && {pipelineError}
}
-
- {latestInsight ? (
- <>
-
- Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
-
-
-
- {!showInsight && (
-
- )}
-
- setShowInsight(s=>!s)}>
- {showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
-
- >
- ) : (
-
- Noch keine KI-Auswertung vorhanden.
- nav('/analysis')}>
- Erste Analyse erstellen
-
-
- )}
-
-
-
- >}
+ {!layoutLoading && layoutForPreview && (
+
+ )}
)
}
diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx
new file mode 100644
index 0000000..ed81efb
--- /dev/null
+++ b/frontend/src/pages/DashboardConfigurePage.jsx
@@ -0,0 +1,416 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { ChevronDown, ChevronUp, LayoutDashboard, Search } from 'lucide-react'
+import { api, formatFastApiDetail } from '../utils/api'
+import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
+import {
+ BODY_CHART_DAYS_DEFAULT,
+ BODY_CHART_DAYS_MAX,
+ BODY_CHART_DAYS_MIN,
+ normalizeBodyChartDays,
+} from '../widgetSystem/bodyChartDays'
+import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
+import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
+import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
+
+const CHART_DAYS_WIDGET_IDS = new Set([
+ 'body_overview',
+ 'activity_overview',
+ 'nutrition_detail_charts',
+ 'recovery_charts_panel',
+])
+
+function catalogMetaById(catalog) {
+ if (!catalog?.widgets?.length) return {}
+ return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
+}
+
+export default function DashboardConfigurePage() {
+ ensurePilotLabWidgetsRegistered()
+
+ const [bundle, setBundle] = useState(null)
+ const [catalog, setCatalog] = useState(null)
+ const [layout, setLayout] = useState(null)
+ const [search, setSearch] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [msg, setMsg] = useState(null)
+ const [err, setErr] = useState(null)
+ const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
+
+ const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
+
+ const isWidgetCatalogAllowed = useCallback(
+ (widgetId) => {
+ const m = metaById[widgetId]
+ if (m == null) return true
+ return m.allowed !== false
+ },
+ [metaById],
+ )
+
+ 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 !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
+ ),
+ }
+ }, [])
+
+ const load = useCallback(async () => {
+ setErr(null)
+ try {
+ const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
+ setCatalog(cat)
+ setBundle(b)
+ setChartDaysDraftByWidgetId({})
+ const base = b.custom ? b.layout : structuredClone(b.product_default_layout)
+ setLayout(normalizeLayoutForEditor(base))
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const save = async () => {
+ if (!layout) return
+ let toSave = layout
+ const draftEntries = Object.entries(chartDaysDraftByWidgetId)
+ if (draftEntries.length) {
+ for (const [wid, val] of draftEntries) {
+ toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
+ }
+ setLayout(toSave)
+ setChartDaysDraftByWidgetId({})
+ }
+ setBusy(true)
+ setMsg(null)
+ setErr(null)
+ try {
+ await api.putAppDashboardLayout(toSave)
+ setMsg('Dein Dashboard wurde gespeichert.')
+ await load()
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const resetToSystem = async () => {
+ if (!window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')) return
+ setBusy(true)
+ setMsg(null)
+ setErr(null)
+ try {
+ const r = await api.resetAppDashboardLayout()
+ setChartDaysDraftByWidgetId({})
+ setLayout(normalizeLayoutForEditor(r.layout))
+ setMsg('Auf System-Standard zurückgesetzt.')
+ await load()
+ } catch (e) {
+ setErr(formatFastApiDetail(null, e.message))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const searchLower = search.trim().toLowerCase()
+
+ const libraryIndices = useMemo(() => {
+ if (!layout?.widgets) return []
+ return layout.widgets
+ .map((w, i) => i)
+ .filter((i) => {
+ const w = layout.widgets[i]
+ if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
+ if (!searchLower) return true
+ const m = metaById[w.id]
+ const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
+ return hay.includes(searchLower)
+ })
+ }, [layout, searchLower, metaById, isWidgetCatalogAllowed])
+
+ const activeIndices = useMemo(() => {
+ if (!layout?.widgets) return []
+ return layout.widgets
+ .map((w, i) => i)
+ .filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
+ }, [layout, isWidgetCatalogAllowed])
+
+ if (err && !layout) {
+ return (
+
+
{err}
+
+ Erneut laden
+
+
+ )
+ }
+
+ if (!layout) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ ← Einstellungen
+
+
+
+ Übersicht anpassen
+
+
+ Wähle Kacheln für deine Startseite. Änderungen gelten nur für dein Profil – der System-Standard bleibt erhalten,
+ bis du speicherst. Gesperrte Kacheln (Abonnement) erscheinen nicht in der Auswahl.
+
+ {!bundle?.custom && (
+
+ Du bearbeitest gerade das System-Standardlayout . Mit „Speichern“ legst du deine persönliche
+ Version ab.
+
+ )}
+
+
+
+
+ Widgets durchsuchen (Bibliothek unten)
+
+
+
+ setSearch(e.target.value)}
+ aria-label="Widgets durchsuchen"
+ style={{ flex: 1 }}
+ />
+
+
+
+
+
+ Auf der Übersicht aktiv · {activeIndices.length}
+
+ {err &&
{err}
}
+ {msg &&
{msg}
}
+
+ {activeIndices.map((i) => {
+ const w = layout.widgets[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 (
+
+
+
+ setLayout((L) => toggleWidget(L, i))} />
+ {label}
+
+
+ setLayout((L) => moveWidget(L, i, -1))}
+ >
+
+
+ setLayout((L) => moveWidget(L, i, 1))}
+ >
+
+
+
+
+ {w.id === 'quick_capture' && (
+
+ setLayout((L) =>
+ normalizeLayoutForEditor({
+ ...L,
+ widgets: L.widgets.map((x, j) => {
+ if (j !== i) return x
+ const cfg = { ...(x.config || {}) }
+ for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
+ delete cfg[k]
+ }
+ Object.assign(cfg, next)
+ return { ...x, config: cfg }
+ }),
+ })
+ )
+ }
+ />
+ )}
+ {w.id === 'kpi_board' && (
+
+ setLayout((L) =>
+ normalizeLayoutForEditor({
+ ...L,
+ widgets: L.widgets.map((x, j) => {
+ if (j !== i) return x
+ const cfg = { ...(x.config || {}) }
+ if (next === undefined) {
+ delete cfg.tiles
+ } else {
+ cfg.tiles = next
+ }
+ return { ...x, config: cfg }
+ }),
+ })
+ )
+ }
+ />
+ )}
+ {CHART_DAYS_WIDGET_IDS.has(w.id) && (
+
+
+ Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
+
+
+ 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(commitChartDaysDraftToLayout(raw, L, w.id))
+ )
+ setChartDaysDraftByWidgetId((prev) => {
+ const next = { ...prev }
+ delete next[w.id]
+ return next
+ })
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') e.currentTarget.blur()
+ }}
+ />
+
+ )}
+
+ )
+ })}
+
+
+
+
+
+ Bibliothek · hinzufügen
+
+ {libraryIndices.length === 0 ? (
+
+ {searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'}
+
+ ) : (
+
+ {libraryIndices.map((i) => {
+ const w = layout.widgets[i]
+ const m = metaById[w.id]
+ return (
+
+
+
{m?.title || w.id}
+
{m?.description || ''}
+
+
+ setLayout((L) =>
+ normalizeLayoutForEditor({
+ ...L,
+ widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
+ })
+ )
+ }
+ >
+ Hinzufügen
+
+
+ )
+ })}
+
+ )}
+
+
+
+
+ Speichern
+
+
+ System-Standard wiederherstellen
+
+
+ Zur Übersicht
+
+
+
+ )
+}
diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx
index 33628db..7145d59 100644
--- a/frontend/src/pages/DashboardLabPage.jsx
+++ b/frontend/src/pages/DashboardLabPage.jsx
@@ -145,10 +145,10 @@ export default function DashboardLabPage() {
}
const applyDefaultLocal = () => {
- if (bundle?.default_layout) {
+ if (bundle?.lab_default_layout) {
setChartDaysDraftByWidgetId({})
- setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout)))
- setMsg('Standard geladen (noch nicht gespeichert).')
+ setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
+ setMsg('Lab-Standard geladen (noch nicht gespeichert).')
}
}
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx
index 5932df9..32cf8c4 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, LayoutGrid } from 'lucide-react'
+import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid, LayoutDashboard } from 'lucide-react'
import { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext'
@@ -428,6 +428,23 @@ export default function SettingsPage() {
+
+
+ Startseite (Übersicht)
+
+
+ Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert – der App-Standard für neue
+ Nutzer wird dadurch nicht überschrieben.
+
+
+ Übersicht anpassen
+
+
+
Strategische Ziele
@@ -449,8 +466,8 @@ export default function SettingsPage() {
Pilot: Visualisierungs-Module
- Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart,
- Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert.
+ Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
+ unter Übersicht anpassen oben.