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

- Updated dashboard layout schema to introduce separate default layouts for product and lab dashboards.
- Added new functions for managing product and lab default layouts, improving user customization options.
- Updated app_dashboard version to 1.9.0 to reflect the introduction of product vs lab layout defaults and new API fields for dashboard configuration.
- Enhanced tests to validate new layout functionalities and ensure proper widget visibility based on user settings.
This commit is contained in:
Lars 2026-04-08 07:41:16 +02:00
parent 9bc0cf70da
commit e4e2f23d7f
12 changed files with 592 additions and 417 deletions

View File

@ -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 from __future__ import annotations
@ -10,7 +10,12 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
from dashboard_widget_config import validate_widget_entry_config 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) # Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul)
__all__ = [ __all__ = [
@ -19,10 +24,13 @@ __all__ = [
"DashboardWidgetEntry", "DashboardWidgetEntry",
"coalesce_effective_layout", "coalesce_effective_layout",
"default_layout_dict", "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 on = DEFAULT_LAB_WIDGET_IDS
return { return {
"version": 1, "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): class DashboardWidgetEntry(BaseModel):
id: str = Field(min_length=1, max_length=64) id: str = Field(min_length=1, max_length=64)
enabled: bool = True enabled: bool = True

View File

@ -9,7 +9,12 @@ from fastapi import APIRouter, Depends, Header, HTTPException
from psycopg2.extras import Json from psycopg2.extras import Json
from auth import require_auth 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 dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
from db import get_cursor, get_db from db import get_cursor, get_db
from routers.profiles import get_pid from routers.profiles import get_pid
@ -47,11 +52,13 @@ def get_dashboard_layout(
custom, effective = coalesce_effective_layout(raw) custom, effective = coalesce_effective_layout(raw)
with get_db() as conn: with get_db() as conn:
effective = apply_entitlements_to_layout_dict(effective, pid, 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 { return {
"custom": custom, "custom": custom,
"layout": effective, "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: if cur.rowcount == 0:
raise HTTPException(404, "Profil nicht gefunden") 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}

View File

@ -6,12 +6,14 @@ from dashboard_layout_schema import (
coalesce_effective_layout, coalesce_effective_layout,
default_layout_dict, default_layout_dict,
) )
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
def test_default_has_all_allowed_ids(): def test_default_has_all_allowed_ids():
d = default_layout_dict() d = default_layout_dict()
got = {w["id"] for w in d["widgets"]} got = {w["id"] for w in d["widgets"]}
assert got == ALLOWED_WIDGET_IDS 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(): def test_payload_rejects_duplicate_ids():
@ -32,7 +34,7 @@ def test_payload_requires_one_enabled():
DashboardLayoutPayload.model_validate( DashboardLayoutPayload.model_validate(
{ {
"version": 1, "version": 1,
"widgets": [{"id": "welcome", "enabled": False}], "widgets": [{"id": "dashboard_greeting", "enabled": False}],
} }
) )

View File

@ -2,7 +2,12 @@
from dashboard_layout_schema import default_layout_dict from dashboard_layout_schema import default_layout_dict
from dashboard_widget_entitlements import widgets_catalog_payload 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(): 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"]] got = [w["id"] for w in d["widgets"]]
assert got == [e["id"] for e in WIDGET_CATALOG] assert got == [e["id"] for e in WIDGET_CATALOG]
enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]} 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"]) 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): def test_catalog_payload_shape(monkeypatch):
monkeypatch.setattr( monkeypatch.setattr(
"dashboard_widget_entitlements._check_feature_access", "dashboard_widget_entitlements._check_feature_access",

View File

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0", "importdata": "1.0.0",
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.6.0", # Phase 4: End Node Template Engine "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 = [ CHANGELOG = [

View File

@ -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) ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)

View File

@ -26,6 +26,7 @@ import SettingsShell from './layouts/SettingsShell'
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
import PilotVizPage from './pages/PilotVizPage' import PilotVizPage from './pages/PilotVizPage'
import DashboardLabPage from './pages/DashboardLabPage' import DashboardLabPage from './pages/DashboardLabPage'
import DashboardConfigurePage from './pages/DashboardConfigurePage'
import GuidePage from './pages/GuidePage' import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage' import AdminFeaturesPage from './pages/AdminFeaturesPage'
@ -232,6 +233,7 @@ function AppShell() {
<Route path="/settings" element={<SettingsShell />}> <Route path="/settings" element={<SettingsShell />}>
<Route index element={<SettingsPage />} /> <Route index element={<SettingsPage />} />
<Route path="reference-values" element={<ProfileReferenceValuesPage />} /> <Route path="reference-values" element={<ProfileReferenceValuesPage />} />
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
</Route> </Route>
<Route element={<RequireAdmin />}> <Route element={<RequireAdmin />}>
<Route path="admin" element={<AdminShell />}> <Route path="admin" element={<AdminShell />}>

View File

@ -5,5 +5,6 @@
export const SETTINGS_SHELL_NAV_ITEMS = [ export const SETTINGS_SHELL_NAV_ITEMS = [
{ id: 'general', label: 'Allgemein', to: '/settings', end: true }, { 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' }, { id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
] ]

View File

@ -1,82 +1,56 @@
import { useState, useEffect } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { Link, useNavigate, useLocation } from 'react-router-dom'
import { Brain } from 'lucide-react' import { LayoutDashboard } from 'lucide-react'
import { api } from '../utils/api' import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { getBfCategory } from '../utils/calc'
import TrialBanner from '../components/TrialBanner' import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner' import EmailVerificationBanner from '../components/EmailVerificationBanner'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution' import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
import SleepWidget from '../components/SleepWidget' import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
import RestDaysWidget from '../components/RestDaysWidget'
import Markdown from '../utils/Markdown' function catalogMetaById(catalog) {
import QuickWeightEntry from '../components/QuickWeightEntry' if (!catalog?.widgets?.length) return {}
import TrendKcalWeightChart from '../components/TrendKcalWeightChart' return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
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')
// Main Dashboard
export default function Dashboard() { export default function Dashboard() {
const nav = useNavigate() const nav = useNavigate()
const location = useLocation() const location = useLocation()
const { activeProfile } = useProfile() const { activeProfile } = useProfile()
const [adminDeniedHint, setAdminDeniedHint] = useState(false) 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 requestRefresh = () => setRefreshTick((t) => t + 1)
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 load = () => Promise.all([ useEffect(() => {
api.getStats(), ensurePilotLabWidgetsRegistered()
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)
})
const runPipeline = async () => { useEffect(() => {
setPipelineLoading(true); setPipelineError(null) let cancel = false
try { setLayoutLoading(true)
await api.insightPipeline() Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
await load() .then(([b, c]) => {
} catch(e) { if (cancel) return
setPipelineError('Fehler: '+e.message) setLayoutBundle(b)
} finally { setPipelineLoading(false) } setCatalog(c)
} })
.catch(() => {
useEffect(()=>{ load() },[]) if (cancel) return
setLayoutBundle(null)
setCatalog(null)
})
.finally(() => {
if (!cancel) setLayoutLoading(false)
})
return () => {
cancel = true
}
}, [])
useEffect(() => { useEffect(() => {
if (!location.state?.adminDenied) return if (!location.state?.adminDenied) return
@ -86,60 +60,19 @@ export default function Dashboard() {
return () => window.clearTimeout(clear) return () => window.clearTimeout(clear)
}, [location.state, nav]) }, [location.state, nav])
useEffect(() => { const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
if (!activeProfile?.id) return
api.listGoals()
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
.catch(() => setGoalsCount(null))
}, [activeProfile?.id])
if (loading) return <div className="empty-state"><div className="spinner"/></div> const layoutForPreview = useMemo(() => {
if (!layoutBundle?.layout) return null
const latestCal = calipers[0] const L = layoutBundle.layout
const latestCir = circs[0] return {
const latestW = weights[0] ...L,
const prevW = weights[1] widgets: L.widgets.map((w) => ({
const sex = activeProfile?.sex||'m' ...w,
const height = activeProfile?.height||178 enabled: w.enabled && metaById[w.id]?.allowed !== false,
})),
// Deltas }
const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null }, [layoutBundle, metaById])
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
return ( return (
<div className="dashboard-page"> <div className="dashboard-page">
@ -157,296 +90,41 @@ export default function Dashboard() {
lineHeight: 1.5, lineHeight: 1.5,
}} }}
> >
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. <strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
Du wurdest zur Übersicht weitergeleitet. Übersicht weitergeleitet.
</div> </div>
)} )}
{/* Header greeting */}
<div className="dashboard-greeting"> <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}> <Link
Hallo, {activeProfile?.name||'Nutzer'} 👋 to="/settings/dashboard-layout"
</h1> className="btn btn-secondary"
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}> style={{
{dayjs().format('dddd, DD. MMMM YYYY')} fontSize: 12,
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`} padding: '8px 12px',
</div> textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
gap: 8,
}}
>
<LayoutDashboard size={16} />
Übersicht anpassen
</Link>
</div> </div>
{/* Email Verification Banner */} {activeProfile && <EmailVerificationBanner profile={activeProfile} />}
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>} {activeProfile && <TrialBanner profile={activeProfile} />}
{/* Trial Banner */} {layoutLoading && (
{activeProfile && <TrialBanner profile={activeProfile}/>}
{!hasAnyData && (
<div className="empty-state"> <div className="empty-state">
<h3>Willkommen bei Mitai Jinkendo!</h3> <div className="spinner" />
<p>Starte mit deiner ersten Messung.</p>
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
Erfassen starten
</button>
</div> </div>
)} )}
{hasAnyData && <> {!layoutLoading && layoutForPreview && (
<DashboardSection <WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
title="Gewicht heute" )}
description="Tageswert erfassen Grundlage für Trends und Ziele."
headerRight={
<button type="button" className="btn btn-secondary"
style={{ fontSize: 12, padding: '6px 12px' }}
onClick={() => nav('/weight')}>
Alle Einträge
</button>
}
>
<div className="card section-gap">
<QuickWeightEntry onSaved={load} />
</div>
</DashboardSection>
<DashboardSection
title="Kennzahlen"
description="Aktuelle Messwerte und Ernährungs-Schnitt (7 Tage)."
>
<div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
<StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??''} unit="kg"
delta={wDelta} deltaGoodWhenNeg={true}
sub={latestW ? dayjs(latestW.date).format('DD.MM.') : ''}
onClick={()=>nav('/history')} color="#378ADD"/>
{latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%"
delta={bfDelta} deltaGoodWhenNeg={true}
sub={bfCat?.label}
onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
{latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg"
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : ''}
onClick={()=>nav('/history',{state:{tab:'body'}})}/>}
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal"
sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
</div>
{pills.length > 0 && (
<div className="dashboard-pill-row">
{pills.map((p,i)=><Pill key={i} {...p}/>)}
</div>
)}
</DashboardSection>
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
<DashboardSection
title="Profil-Ziele"
description="Fortschritt zu den Zielwerten in deinem Profil."
>
<div className="card section-gap">
{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 (
<div style={{marginBottom:10}}>
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
<span>Gewicht: {curr} {goal} kg</span>
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'}</span>
</div>
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
<div style={{height:'100%',width:`${pct}%`,background:'var(--accent)',borderRadius:4,transition:'width 0.5s'}}/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{pct}% des Weges</div>
</div>
)
})()}
{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 (
<div>
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
<span>Körperfett: {curr}% {goal}%</span>
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'}</span>
</div>
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
<div style={{height:'100%',width:`${pct}%`,background:bfCat?.color||'var(--accent)',borderRadius:4}}/>
</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>Aktuell: {bfCat?.label}</div>
</div>
)
})()}
</div>
</DashboardSection>
)}
{(weights.length>2||nutrition.length>2) && (
<DashboardSection
title="Trends"
description="Kalorien und Gewicht der letzten 30 Tage."
headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={()=>nav('/history',{state:{tab:'body'}})}>
Details
</button>
}
>
<DashboardTile>
<div className="card section-gap">
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={30} />
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
</div>
</div>
</DashboardTile>
</DashboardSection>
)}
{(showNutrSummary || showActSummary) && (
<DashboardSection
title="Ernährung & Aktivität"
description="Kurzüberblick; volle Verläufe unter Historie."
>
<div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
{showNutrSummary && (
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽 ERNÄHRUNG (Ø 7T)</div>
{avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>}
{avgProtein && <div style={{fontSize:13,fontWeight:600,
color:proteinOk?'var(--accent)':'var(--warn)'}}>
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
</div>}
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Ernährung</div>
</div>
</DashboardTile>
)}
{showActSummary && (
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋 AKTIVITÄT (7T)</div>
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}> Verlauf Aktivität</div>
</div>
</DashboardTile>
)}
</div>
</DashboardSection>
)}
<DashboardSection
title="Erholung"
description="Schlaf und Ruhetage im Überblick."
>
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
<DashboardTile spanMobile={1} spanDesktop={2}>
<SleepWidget/>
</DashboardTile>
<DashboardTile spanMobile={1} spanDesktop={2}>
<RestDaysWidget/>
</DashboardTile>
</div>
</DashboardSection>
{activities.length > 0 && (
<DashboardSection
title="Training"
description="Verteilung der Trainingstypen (28 Tage)."
headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={()=>nav('/activity')}>
Details
</button>
}
>
<DashboardTile>
<div className="card section-gap">
<TrainingTypeDistribution days={28} />
</div>
</DashboardTile>
</DashboardSection>
)}
<DashboardSection
title="Ziele & Fokus"
description="Strategische Ziele und Schwerpunkte eigener Menüpunkt „Ziele“, Kontext für KI und Dashboard."
headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
Ziele bearbeiten
</button>
}
>
<DashboardTile>
<div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
{goalsCount != null && (
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 8 }}>
{goalsCount === 0
? 'Noch keine Ziele angelegt.'
: `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--text2)', padding: goalsCount != null ? '0 0 8px' : '8px 0' }}>
Hier pflegst du Focus Areas, Meilensteine und Fortschritt unabhängig von der KI-Analyse-Seite.
Tippen zum Öffnen oder unten in der Navigation <strong>Ziele</strong> wählen.
</div>
</div>
</DashboardTile>
</DashboardSection>
<DashboardSection
title="KI-Auswertung"
description="Mehrstufige Pipeline und letzte Zusammenfassung."
headerRight={
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }}
onClick={()=>nav('/analysis')}>
<Brain size={11}/> Analysen
</button>
}
>
<DashboardTile>
<div className="card section-gap">
<button type="button" className="btn btn-primary btn-full" style={{marginBottom:10}}
onClick={runPipeline} disabled={pipelineLoading}>
{pipelineLoading
? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft (3 Stufen)</>
: <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>}
</button>
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>}
{latestInsight ? (
<>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
</div>
<div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}>
<Markdown text={latestInsight.content}/>
{!showInsight && (
<div style={{position:'absolute',bottom:0,left:0,right:0,height:40,
background:'linear-gradient(transparent,var(--surface))'}}/>
)}
</div>
<button type="button" style={{background:'none',border:'none',cursor:'pointer',
fontSize:12,color:'var(--accent)',marginTop:6,padding:0}}
onClick={()=>setShowInsight(s=>!s)}>
{showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
</button>
</>
) : (
<div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}>
Noch keine KI-Auswertung vorhanden.
<button type="button" className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
onClick={()=>nav('/analysis')}>
Erste Analyse erstellen
</button>
</div>
)}
</div>
</DashboardTile>
</DashboardSection>
</>}
</div> </div>
) )
} }

View File

@ -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 (
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
<p style={{ color: '#D85A30' }}>{err}</p>
<button type="button" className="btn btn-secondary" onClick={load}>
Erneut laden
</button>
</div>
)
}
if (!layout) {
return (
<div style={{ padding: 48, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
return (
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 720, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Link
to="/settings"
className="btn btn-secondary"
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
>
Einstellungen
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<LayoutDashboard size={26} color="var(--accent)" />
Übersicht anpassen
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
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.
</p>
{!bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
Du bearbeitest gerade das <strong>System-Standardlayout</strong>. Mit Speichern legst du deine persönliche
Version ab.
</p>
)}
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 6 }}>
Widgets durchsuchen (Bibliothek unten)
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Search size={18} color="var(--text3)" style={{ flexShrink: 0 }} />
<input
type="search"
className="form-input"
placeholder="Titel, Beschreibung oder ID …"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Widgets durchsuchen"
style={{ flex: 1 }}
/>
</div>
</div>
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div className="card-title" style={{ fontSize: 15 }}>
Auf der Übersicht aktiv · {activeIndices.length}
</div>
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{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 (
<li
key={w.id}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
<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 === 'quick_capture' && (
<QuickCaptureConfigEditor
config={w.config || {}}
onChange={(next) =>
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' && (
<KpiBoardConfigEditor
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
onChange={(next) =>
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) && (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
type="text"
inputMode="numeric"
autoComplete="off"
className="form-input"
style={{ maxWidth: 120 }}
value={
chartDaysDraftByWidgetId[w.id] !== undefined
? chartDaysDraftByWidgetId[w.id]
: String(chartDaysVal)
}
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(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()
}}
/>
</div>
)}
</li>
)
})}
</ul>
</div>
<div className="card section-gap" style={{ marginBottom: 20 }}>
<div className="card-title" style={{ fontSize: 15 }}>
Bibliothek · hinzufügen
</div>
{libraryIndices.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
{searchLower ? 'Keine passenden inaktiven Widgets.' : 'Alle verfügbaren Kacheln sind schon aktiv.'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{libraryIndices.map((i) => {
const w = layout.widgets[i]
const m = metaById[w.id]
return (
<li
key={w.id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '10px 0',
borderBottom: '1px solid var(--border)',
}}
>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
</div>
<button
type="button"
className="btn btn-primary"
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
onClick={() =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
})
)
}
>
Hinzufügen
</button>
</li>
)
})}
</ul>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
Speichern
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={resetToSystem}>
System-Standard wiederherstellen
</button>
<Link to="/" className="btn btn-secondary" style={{ textDecoration: 'none', textAlign: 'center' }}>
Zur Übersicht
</Link>
</div>
</div>
)
}

View File

@ -145,10 +145,10 @@ export default function DashboardLabPage() {
} }
const applyDefaultLocal = () => { const applyDefaultLocal = () => {
if (bundle?.default_layout) { if (bundle?.lab_default_layout) {
setChartDaysDraftByWidgetId({}) setChartDaysDraftByWidgetId({})
setLayout(normalizeLayoutForEditor(structuredClone(bundle.default_layout))) setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
setMsg('Standard geladen (noch nicht gespeichert).') setMsg('Lab-Standard geladen (noch nicht gespeichert).')
} }
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' 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 { Link } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext' import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
@ -428,6 +428,23 @@ export default function SettingsPage() {
</button> </button>
</div> </div>
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert der App-Standard für neue
Nutzer wird dadurch nicht überschrieben.
</p>
<Link
to="/settings/dashboard-layout"
className="btn btn-primary btn-full"
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
>
Übersicht anpassen
</Link>
</div>
<div className="card section-gap"> <div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Target size={15} color="var(--accent)" /> Strategische Ziele <Target size={15} color="var(--accent)" /> Strategische Ziele
@ -449,8 +466,8 @@ export default function SettingsPage() {
Pilot: Visualisierungs-Module Pilot: Visualisierungs-Module
</div> </div>
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}> <p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs (Referenzen + KF% + Ø-Kalorien), Körper-Chart, Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
Bewertungen, Aktivität. Produktives Dashboard bleibt unverändert. unter <strong>Übersicht anpassen</strong> oben.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Link <Link