)
}
// ── Status Pill ───────────────────────────────────────────────────────────────
const PILL_TOOLTIPS = {
'WHR': 'Waist-Hip-Ratio: Taille ÷ Hüfte. Maß für Bauchfettverteilung. Ziel: <0,90 (M) / <0,85 (F)',
'WHtR': 'Waist-to-Height-Ratio: Taille ÷ Körpergröße. Gesündestest Maß: Ziel unter 0,50.',
'KF': 'Körperfettanteil in Prozent (aus Caliper-Messung).',
'Protein Ø7T': 'Durchschnittliche tägliche Proteinaufnahme der letzten 7 Tage vs. Zielbereich (1,6–2,2g/kg KG).',
}
function Pill({ label, value, status, sub }) {
const [tip, setTip] = useState(false)
const color = status==='good'?'var(--accent)':status==='warn'?'var(--warn)':'#D85A30'
const bg = status==='good'?'var(--accent-light)':status==='warn'?'var(--warn-bg)':'#FCEBEB'
const tipText = PILL_TOOLTIPS[label]
return (
tipText&&setTip(s=>!s)}
style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
borderRadius:20,background:bg,border:`1px solid ${color}44`,
cursor:tipText?'help':'default'}}>
{label}
{value}
{sub && {sub}}
{tipText && ⓘ}
{tip && tipText && (
setTip(false)} style={{
position:'absolute',bottom:'110%',left:0,zIndex:50,
background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,padding:'8px 10px',fontSize:11,color:'var(--text2)',
minWidth:200,maxWidth:260,lineHeight:1.5,
boxShadow:'0 4px 16px rgba(0,0,0,0.15)'}}>
{label}
{tipText}
)}
)
}
// ── Stat Card ─────────────────────────────────────────────────────────────────
function StatCard({ icon, label, value, unit, delta, deltaGoodWhenNeg=false, sub, onClick, color }) {
const deltaColor = delta==null ? null
: (deltaGoodWhenNeg ? delta<0 : delta>0) ? 'var(--accent)' : 'var(--warn)'
return (
onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
{icon}
{label}
{value}{unit}
{delta!=null &&
{delta>0?'+':''}{delta} {unit}
}
{sub &&
{sub}
}
)
}
// ── Combined Chart: Kcal + Weight ─────────────────────────────────────────────
function ComboChart({ weights, nutrition }) {
// Build unified date axis from last 30 days
const days = []
for (let i=29; i>=0; i--) days.push(dayjs().subtract(i,'day').format('YYYY-MM-DD'))
const wMap = {}; (weights||[]).forEach(w=>{ wMap[w.date]=w.weight })
const nMap = {}; (nutrition||[]).forEach(n=>{ nMap[n.date]=Math.round(n.kcal||0) })
// Forward-fill weight: carry last known weight to fill gaps
let lastW = null
const combined = days.map(date=>{
if (wMap[date]) lastW = wMap[date]
return {
date: dayjs(date).format('DD.MM'),
kcal: nMap[date]||null,
weight: wMap[date]||null, // actual measurement dots
weightLine:lastW, // interpolated line
}
}).filter(d=>d.kcal||d.weightLine)
const withAvg = rollingAvg(combined,'kcal')
const hasKcal = combined.some(d=>d.kcal)
const hasW = combined.some(d=>d.weightLine)
if (!hasKcal && !hasW) return (
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
)
return (
{hasKcal && }
{hasW && }
[v==null?'–':`${Math.round(v)} ${n==='weightLine'||n==='weight'?'kg':'kcal'}`,
n==='kcal_avg'?'Ø Kalorien (7T)':n==='kcal'?'Kalorien':n==='weightLine'?'Gewicht (interpoliert)':'Gewicht Messung']}/>
{hasKcal && }
{hasKcal && }
{hasW && }
{hasW && { const {cx,cy,value}=props; return value!=null?:}} connectNulls={false} name="weight"/>}
)
}
// ── Main Dashboard ────────────────────────────────────────────────────────────
export default function Dashboard() {
const nav = useNavigate()
const { activeProfile } = useProfile()
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(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)
})
const runPipeline = async () => {
setPipelineLoading(true); setPipelineError(null)
try {
const pid = localStorage.getItem('mitai-jinkendo_active_profile')||''
await api.insightPipeline()
await load()
} catch(e) {
setPipelineError('Fehler: '+e.message)
} finally { setPipelineLoading(false) }
}
useEffect(()=>{ load() },[])
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
return (
{/* Header greeting */}
Hallo, {activeProfile?.name||'Nutzer'} 👋
{dayjs().format('dddd, DD. MMMM YYYY')}
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
{/* Trial Banner */}
{!hasAnyData && (
Willkommen bei Mitai Jinkendo!
Starte mit deiner ersten Messung.
)}
{hasAnyData && <>
{/* Quick weight entry */}
⚖️ Gewicht heute
{/* Key metrics */}
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"/>}
{/* Status pills */}
{pills.length > 0 && (
)}
{/* Goals progress */}
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
🎯 Ziele
{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! 🎉'}
)
})()}
)}
{/* Combined chart */}
{(weights.length>2||nutrition.length>2) && (
📊 Kalorien + Gewicht (30 Tage)
Ø Kalorien
Gewicht
)}
{/* Activity + Nutrition summary row */}
{(avgKcal||avgProtein) && (
nav('/history',{state:{tab:'nutrition'}})}>
🍽️ ERNÄHRUNG (Ø 7T)
{avgKcal &&
{avgKcal} kcal
}
{avgProtein &&
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
}
→ Verlauf Ernährung
)}
{actKcal!=null && (
nav('/history',{state:{tab:'activity'}})}>
🏋️ AKTIVITÄT (7T)
{actKcal} kcal
{recentAct.length} Trainings
→ Verlauf Aktivität
)}
{/* Latest AI insight */}
🤖 KI-Auswertung
{/* Pipeline trigger */}
)
}