Component:
- TrialBanner.jsx: Displays remaining trial days with urgency levels
Features:
- Calculates days left from profile.trial_ends_at
- Three urgency levels:
* Normal (>7 days): Accent blue, "Abo wählen"
* Warning (≤7 days): Orange, "Abo wählen"
* Urgent (≤3 days): Red + ⚠️, "Jetzt upgraden"
- Auto-hides when no trial or trial ended
- Responsive flex layout
- Call-to-action button links to /settings?tab=subscription
Integration:
- Added to Dashboard after header greeting
- Uses activeProfile from ProfileContext
- Clean, non-intrusive design
UX:
- Clear messaging: "Trial endet in X Tagen"
- Special case: "morgen" for 1 day left
- Color-coded severity (blue → orange → red)
- Prominent CTA button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
506 lines
25 KiB
JavaScript
506 lines
25 KiB
JavaScript
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 (
|
||
<div>
|
||
{error && (
|
||
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
||
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
||
<div title={tooltipText} style={{display:'inline-block'}}>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
|
||
onClick={handleSave}
|
||
disabled={isDisabled}
|
||
>
|
||
{saved ? <Check size={15}/>
|
||
: saving ? <div className="spinner" style={{width:14,height:14}}/>
|
||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
|
||
: 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{position:'relative'}}>
|
||
<div onClick={()=>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'}}>
|
||
<div style={{width:7,height:7,borderRadius:'50%',background:color,flexShrink:0}}/>
|
||
<span style={{fontSize:12,fontWeight:500,color:'var(--text2)'}}>{label}</span>
|
||
<span style={{fontSize:12,fontWeight:700,color}}>{value}</span>
|
||
{sub && <span style={{fontSize:10,color:'var(--text3)'}}>{sub}</span>}
|
||
{tipText && <span style={{fontSize:10,color:'var(--text3)',opacity:0.7}}>ⓘ</span>}
|
||
</div>
|
||
{tip && tipText && (
|
||
<div onClick={()=>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)'}}>
|
||
<strong>{label}</strong><br/>{tipText}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div onClick={onClick} style={{
|
||
flex:1, minWidth:80, background:'var(--surface)', borderRadius:12,
|
||
padding:'12px 10px', cursor:onClick?'pointer':'default',
|
||
border:'1px solid var(--border)', transition:'border-color 0.15s',
|
||
}}
|
||
onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
|
||
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
|
||
<div style={{fontSize:18,marginBottom:4}}>{icon}</div>
|
||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:2}}>{label}</div>
|
||
<div style={{fontSize:19,fontWeight:700,color:color||'var(--text1)',lineHeight:1.1}}>
|
||
{value}<span style={{fontSize:12,fontWeight:400,color:'var(--text3)',marginLeft:2}}>{unit}</span>
|
||
</div>
|
||
{delta!=null && <div style={{fontSize:11,fontWeight:600,color:deltaColor,marginTop:2}}>
|
||
{delta>0?'+':''}{delta} {unit}
|
||
</div>}
|
||
{sub && <div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{sub}</div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 (
|
||
<div style={{padding:20,textAlign:'center',fontSize:12,color:'var(--text3)'}}>
|
||
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
|
||
</div>
|
||
)
|
||
|
||
return (
|
||
<ResponsiveContainer width="100%" height={160}>
|
||
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
|
||
{hasKcal && <YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
||
{hasW && <YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||
formatter={(v,n)=>[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 && <Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>}
|
||
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} connectNulls={true} name="kcal_avg"/>}
|
||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weightLine" stroke="#378ADD88" strokeWidth={1.5} dot={false} connectNulls={true} name="weightLine"/>}
|
||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={0}
|
||
dot={(props)=>{ const {cx,cy,value}=props; return value!=null?<circle key={cx} cx={cx} cy={cy} r={4} fill="#378ADD" stroke="white" strokeWidth={1.5}/>:<g key={cx}/>}} connectNulls={false} name="weight"/>}
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)
|
||
}
|
||
|
||
// ── 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 <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
|
||
|
||
return (
|
||
<div>
|
||
{/* Header greeting */}
|
||
<div style={{marginBottom:16}}>
|
||
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
||
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
||
</h1>
|
||
<div 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>
|
||
|
||
{/* Trial Banner */}
|
||
<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 && <>
|
||
{/* Quick weight entry */}
|
||
<div className="card section-gap">
|
||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:10}}>
|
||
<div style={{fontWeight:600,fontSize:14}}>⚖️ Gewicht heute</div>
|
||
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
|
||
onClick={()=>nav('/weight')}>
|
||
Alle Einträge →
|
||
</button>
|
||
</div>
|
||
<QuickWeight onSaved={load}/>
|
||
</div>
|
||
|
||
{/* Key metrics */}
|
||
<div style={{display:'flex',gap:8,marginBottom:16,flexWrap:'wrap'}}>
|
||
<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>
|
||
|
||
{/* Status pills */}
|
||
{pills.length > 0 && (
|
||
<div style={{display:'flex',gap:6,flexWrap:'wrap',marginBottom:16}}>
|
||
{pills.map((p,i)=><Pill key={i} {...p}/>)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Goals progress */}
|
||
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
|
||
<div className="card section-gap" style={{marginBottom:16}}>
|
||
<div style={{fontWeight:600,fontSize:13,marginBottom:10}}>🎯 Ziele</div>
|
||
{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>
|
||
)}
|
||
|
||
{/* Combined chart */}
|
||
{(weights.length>2||nutrition.length>2) && (
|
||
<div className="card section-gap" style={{marginBottom:16}}>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:8}}>
|
||
<div style={{fontWeight:600,fontSize:13}}>📊 Kalorien + Gewicht (30 Tage)</div>
|
||
<button style={{background:'none',border:'none',fontSize:12,color:'var(--accent)',cursor:'pointer'}}
|
||
onClick={()=>nav('/history',{state:{tab:'body'}})}>
|
||
Details →
|
||
</button>
|
||
</div>
|
||
<ComboChart weights={weights} nutrition={nutrition}/>
|
||
<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>
|
||
)}
|
||
|
||
{/* Activity + Nutrition summary row */}
|
||
<div style={{display:'flex',gap:8,marginBottom:16}}>
|
||
{(avgKcal||avgProtein) && (
|
||
<div className="card" style={{flex:1,cursor:'pointer'}} 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>
|
||
)}
|
||
{actKcal!=null && (
|
||
<div className="card" style={{flex:1,cursor:'pointer'}} 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>
|
||
)}
|
||
</div>
|
||
|
||
{/* Latest AI insight */}
|
||
<div className="card section-gap">
|
||
<div style={{display:'flex',alignItems:'center',justifyContent:'space-between',marginBottom:8}}>
|
||
<div style={{fontWeight:600,fontSize:13}}>🤖 KI-Auswertung</div>
|
||
<button className="btn btn-secondary" style={{fontSize:11,padding:'4px 10px'}}
|
||
onClick={()=>nav('/analysis')}>
|
||
<Brain size={11}/> Analysen →
|
||
</button>
|
||
</div>
|
||
{/* Pipeline trigger */}
|
||
<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 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 className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
|
||
onClick={()=>nav('/analysis')}>
|
||
Erste Analyse erstellen
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>}
|
||
</div>
|
||
)
|
||
}
|