import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { Check, ChevronRight, Brain } from 'lucide-react' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' import { useProfile } from '../context/ProfileContext' import { getBfCategory } from '../utils/calc' import TrialBanner from '../components/TrialBanner' import { getInterpretation, getStatusColor, getStatusBg } from '../utils/interpret' import Markdown from '../utils/Markdown' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') // ── Helpers ─────────────────────────────────────────────────────────────────── function rollingAvg(arr, key, w=7) { return arr.map((d,i)=>{ const s=arr.slice(Math.max(0,i-w+1),i+1).map(x=>x[key]).filter(v=>v!=null) return s.length?{...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10}:d }) } // ── Quick Weight Entry ──────────────────────────────────────────────────────── function QuickWeight({ onSaved }) { const [input, setInput] = useState('') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [error, setError] = useState(null) const [weightUsage, setWeightUsage] = useState(null) const today = dayjs().format('YYYY-MM-DD') const loadUsage = () => { api.getFeatureUsage().then(features => { const weightFeature = features.find(f => f.feature_id === 'weight_entries') setWeightUsage(weightFeature) }).catch(err => console.error('Failed to load usage:', err)) } useEffect(()=>{ api.weightStats().then(s=>{ if(s?.latest?.date===today) setInput(String(s.latest.weight)) }) loadUsage() },[]) const handleSave = async () => { const w=parseFloat(input); if(!w||w<20||w>300) return setSaving(true) setError(null) try{ await api.upsertWeight(today,w) setSaved(true) await loadUsage() // Reload usage after save onSaved?.() setTimeout(()=>setSaved(false),2000) } catch(err) { console.error('Save failed:', err) setError(err.message || 'Fehler beim Speichern') setTimeout(()=>setError(null), 5000) } finally { setSaving(false) } } const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed) const tooltipText = weightUsage && !weightUsage.allowed ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : '' return (
{error && (
{error}
)}
setInput(e.target.value)} onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/> kg
) } // ── 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 && (
{pills.map((p,i)=>)}
)} {/* 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! 🎉'}
Aktuell: {bfCat?.label}
) })()}
)} {/* 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 */} {pipelineError &&
{pipelineError}
} {latestInsight ? ( <>
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
{!showInsight && (
)}
) : (
Noch keine KI-Auswertung vorhanden.
)}
}
) }