2026-04-05 08:42:07 +02:00
|
|
|
|
import { useState, useEffect } from 'react'
|
2026-04-05 11:28:33 +02:00
|
|
|
|
import { useNavigate, useLocation } from 'react-router-dom'
|
2026-04-07 14:19:45 +02:00
|
|
|
|
import { Brain } from 'lucide-react'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
import { api } from '../utils/api'
|
|
|
|
|
|
import { useProfile } from '../context/ProfileContext'
|
|
|
|
|
|
import { getBfCategory } from '../utils/calc'
|
2026-03-21 09:56:35 +01:00
|
|
|
|
import TrialBanner from '../components/TrialBanner'
|
2026-03-21 10:20:06 +01:00
|
|
|
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
2026-03-21 14:56:11 +01:00
|
|
|
|
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
2026-03-22 08:17:11 +01:00
|
|
|
|
import SleepWidget from '../components/SleepWidget'
|
2026-03-23 08:38:57 +01:00
|
|
|
|
import RestDaysWidget from '../components/RestDaysWidget'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
import Markdown from '../utils/Markdown'
|
2026-04-07 14:19:45 +02:00
|
|
|
|
import QuickWeightEntry from '../components/QuickWeightEntry'
|
|
|
|
|
|
import TrendKcalWeightChart from '../components/TrendKcalWeightChart'
|
|
|
|
|
|
import { Pill, StatCard } from '../components/DashboardStatKit'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import 'dayjs/locale/de'
|
2026-04-05 08:42:07 +02:00
|
|
|
|
import DashboardSection from '../components/DashboardSection'
|
|
|
|
|
|
import DashboardTile from '../components/DashboardTile'
|
|
|
|
|
|
import {
|
|
|
|
|
|
DASHBOARD_TILE_GRID_COLS,
|
|
|
|
|
|
dashboardStatGridClassName,
|
|
|
|
|
|
dashboardTileGridClassName
|
|
|
|
|
|
} from '../utils/dashboardLayout'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
dayjs.locale('de')
|
|
|
|
|
|
|
|
|
|
|
|
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
|
|
|
|
|
export default function Dashboard() {
|
|
|
|
|
|
const nav = useNavigate()
|
2026-04-05 11:28:33 +02:00
|
|
|
|
const location = useLocation()
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const { activeProfile } = useProfile()
|
|
|
|
|
|
|
2026-04-05 11:28:33 +02:00
|
|
|
|
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
|
2026-04-05 12:07:11 +02:00
|
|
|
|
const [goalsCount, setGoalsCount] = useState(null)
|
2026-04-05 11:28:33 +02:00
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
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),
|
2026-04-07 12:28:59 +02:00
|
|
|
|
api.listActivity(800, 30),
|
2026-03-16 13:35:11 +01:00
|
|
|
|
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)
|
2026-03-21 11:56:09 +01:00
|
|
|
|
}).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)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const runPipeline = async () => {
|
|
|
|
|
|
setPipelineLoading(true); setPipelineError(null)
|
|
|
|
|
|
try {
|
2026-03-16 16:52:36 +01:00
|
|
|
|
await api.insightPipeline()
|
2026-03-16 13:35:11 +01:00
|
|
|
|
await load()
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
setPipelineError('Fehler: '+e.message)
|
|
|
|
|
|
} finally { setPipelineLoading(false) }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-05 08:42:07 +02:00
|
|
|
|
useEffect(()=>{ load() },[])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-04-05 11:28:33 +02:00
|
|
|
|
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])
|
|
|
|
|
|
|
2026-04-05 12:07:11 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!activeProfile?.id) return
|
|
|
|
|
|
api.listGoals()
|
|
|
|
|
|
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
|
|
|
|
|
|
.catch(() => setGoalsCount(null))
|
|
|
|
|
|
}, [activeProfile?.id])
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-04-05 08:42:07 +02:00
|
|
|
|
const showNutrSummary = !!(avgKcal || avgProtein)
|
|
|
|
|
|
const showActSummary = actKcal != null
|
|
|
|
|
|
const summaryBoth = showNutrSummary && showActSummary
|
|
|
|
|
|
const summarySpanM = summaryBoth ? 1 : 2
|
|
|
|
|
|
const summarySpanD = summaryBoth ? 2 : 4
|
2026-03-21 11:56:09 +01:00
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
return (
|
2026-04-05 08:03:53 +02:00
|
|
|
|
<div className="dashboard-page">
|
2026-04-05 11:28:33 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{/* Header greeting */}
|
2026-04-05 08:03:53 +02:00
|
|
|
|
<div className="dashboard-greeting">
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
|
|
|
|
|
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
|
|
|
|
|
</h1>
|
2026-04-05 08:03:53 +02:00
|
|
|
|
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{dayjs().format('dddd, DD. MMMM YYYY')}
|
|
|
|
|
|
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-21 10:20:06 +01:00
|
|
|
|
{/* Email Verification Banner */}
|
2026-03-21 11:56:09 +01:00
|
|
|
|
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
|
2026-03-21 10:20:06 +01:00
|
|
|
|
|
2026-03-21 09:56:35 +01:00
|
|
|
|
{/* Trial Banner */}
|
2026-03-21 11:56:09 +01:00
|
|
|
|
{activeProfile && <TrialBanner profile={activeProfile}/>}
|
2026-03-21 09:56:35 +01:00
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{!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 && <>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<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')}>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
Alle Einträge →
|
|
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="card section-gap">
|
2026-04-07 14:19:45 +02:00
|
|
|
|
<QuickWeightEntry onSaved={load} />
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
</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"/>}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
{pills.length > 0 && (
|
|
|
|
|
|
<div className="dashboard-pill-row">
|
|
|
|
|
|
{pills.map((p,i)=><Pill key={i} {...p}/>)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</DashboardSection>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<DashboardSection
|
|
|
|
|
|
title="Profil-Ziele"
|
|
|
|
|
|
description="Fortschritt zu den Zielwerten in deinem Profil."
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="card section-gap">
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{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>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
</DashboardSection>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{(weights.length>2||nutrition.length>2) && (
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<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' }}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
onClick={()=>nav('/history',{state:{tab:'body'}})}>
|
|
|
|
|
|
Details →
|
|
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<DashboardTile>
|
|
|
|
|
|
<div className="card section-gap">
|
2026-04-07 14:19:45 +02:00
|
|
|
|
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={30} />
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-05 08:42:07 +02:00
|
|
|
|
{(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>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
</DashboardSection>
|
|
|
|
|
|
)}
|
2026-03-23 08:38:57 +01:00
|
|
|
|
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
2026-03-21 14:56:11 +01:00
|
|
|
|
{activities.length > 0 && (
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<DashboardSection
|
|
|
|
|
|
title="Training"
|
|
|
|
|
|
description="Verteilung der Trainingstypen (28 Tage)."
|
|
|
|
|
|
headerRight={
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
2026-03-21 14:56:11 +01:00
|
|
|
|
onClick={()=>nav('/activity')}>
|
|
|
|
|
|
Details →
|
|
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<DashboardTile>
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<TrainingTypeDistribution days={28} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</DashboardTile>
|
|
|
|
|
|
</DashboardSection>
|
2026-03-21 14:56:11 +01:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-04-05 08:42:07 +02:00
|
|
|
|
<DashboardSection
|
|
|
|
|
|
title="Ziele & Fokus"
|
2026-04-05 12:07:11 +02:00
|
|
|
|
description="Strategische Ziele und Schwerpunkte – eigener Menüpunkt „Ziele“, Kontext für KI und Dashboard."
|
2026-04-05 08:42:07 +02:00
|
|
|
|
headerRight={
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
|
|
|
|
onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
|
2026-04-05 12:07:11 +02:00
|
|
|
|
Ziele bearbeiten →
|
feat: Phase 0a - Minimal Goal System (Strategic + Tactical)
- Strategic Layer: Goal modes (weight_loss, strength, endurance, recomposition, health)
- Tactical Layer: Concrete goal targets with progress tracking
- Training phases (manual + auto-detection framework)
- Fitness tests (standardized performance tracking)
Backend:
- Migration 022: goal_mode in profiles, goals, training_phases, fitness_tests tables
- New router: routers/goals.py with full CRUD for goals, phases, tests
- API endpoints: /api/goals/* (mode, list, create, update, delete)
Frontend:
- GoalsPage: Goal mode selector + goal management UI
- Dashboard: Goals preview card with link
- API integration: goal mode, CRUD operations, progress calculation
Basis for 120+ placeholders and goal-aware analyses (Phase 0b)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 16:20:35 +01:00
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<DashboardTile>
|
|
|
|
|
|
<div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
|
2026-04-05 12:07:11 +02:00
|
|
|
|
{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.
|
2026-04-05 08:42:07 +02:00
|
|
|
|
</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' }}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
onClick={()=>nav('/analysis')}>
|
|
|
|
|
|
<Brain size={11}/> Analysen →
|
|
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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</>}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</button>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</div>
|
2026-04-05 08:42:07 +02:00
|
|
|
|
</DashboardTile>
|
|
|
|
|
|
</DashboardSection>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|