mitai-jinkendo/frontend/src/pages/Dashboard.jsx

453 lines
21 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { Brain } 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')
// ── 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 [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 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)
})
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(() => {
if (!location.state?.adminDenied) return
setAdminDeniedHint(true)
nav('.', { replace: true, state: null })
const clear = window.setTimeout(() => setAdminDeniedHint(false), 8000)
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])
if (loading) return <div className="empty-state"><div className="spinner"/></div>
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
return (
<div className="dashboard-page">
{adminDeniedHint && (
<div
role="alert"
style={{
marginBottom: 14,
padding: '12px 14px',
borderRadius: 10,
background: '#FCEBEB',
border: '1px solid #D85A3033',
fontSize: 13,
color: '#D85A30',
lineHeight: 1.5,
}}
>
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle.
Du wurdest zur Übersicht weitergeleitet.
</div>
)}
{/* Header greeting */}
<div className="dashboard-greeting">
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
Hallo, {activeProfile?.name||'Nutzer'} 👋
</h1>
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
{dayjs().format('dddd, DD. MMMM YYYY')}
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
</div>
</div>
{/* Email Verification Banner */}
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
{/* Trial Banner */}
{activeProfile && <TrialBanner profile={activeProfile}/>}
{!hasAnyData && (
<div className="empty-state">
<h3>Willkommen bei Mitai Jinkendo!</h3>
<p>Starte mit deiner ersten Messung.</p>
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
Erfassen starten
</button>
</div>
)}
{hasAnyData && <>
<DashboardSection
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>
)
}