mitai-jinkendo/frontend/src/pages/ActivityPage.jsx
Lars ed057fe545
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s
feat: complete Phase 4 enforcement UI for all features (frontend)
Alle verbleibenden Screens mit proaktiver Limit-Anzeige:

- ActivityPage: Manuelle Einträge mit Badge + deaktiviertem Button
- Analysis: AI-Analysen (Pipeline + Einzelanalysen) mit Hover-Tooltip
- NutritionPage: Hat bereits Error-Handling (bulk import)

Konsistentes Pattern:
- Usage-Badge im Titel
- Button deaktiviert + Hover-Tooltip bei Limit
- "🔒 Limit erreicht" Button-Text
- Error-Handling für API-Fehler
- Usage reload nach erfolgreichem Speichern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 07:42:50 +01:00

364 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
'Cardio Dance','Geist & Körper','Sonstiges'
]
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
activity_type: 'Traditionelles Krafttraining',
duration_min: '', kcal_active: '',
hr_avg: '', hr_max: '', rpe: '', notes: ''
}
}
// ── Import Panel ──────────────────────────────────────────────────────────────
function ImportPanel({ onImported }) {
const fileRef = useRef()
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
const [dragging, setDragging] = useState(false)
const runImport = async (file) => {
setStatus('loading'); setError(null)
try {
const result = await api.importActivityCsv(file)
setStatus(result); onImported()
} catch(err) {
setError('Import fehlgeschlagen: ' + err.message); setStatus(null)
}
}
return (
<div className="card section-gap">
<div className="card-title">📥 Apple Health Import</div>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:10,lineHeight:1.6}}>
<strong>Health Auto Export App</strong> → Workouts exportieren → CSV → hier hochladen.<br/>
Nur die <em>Workouts-csv</em> Datei wird benötigt (nicht die Detaildateien).
</p>
<input ref={fileRef} type="file" accept=".csv" style={{display:'none'}}
onChange={e=>{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
<div
onDragOver={e=>{e.preventDefault();setDragging(true)}}
onDragLeave={()=>setDragging(false)}
onDrop={e=>{e.preventDefault();setDragging(false);const f=e.dataTransfer.files[0];if(f)runImport(f)}}
onClick={()=>fileRef.current.click()}
style={{border:`2px dashed ${dragging?'var(--accent)':'var(--border2)'}`,borderRadius:10,
padding:'20px 16px',textAlign:'center',background:dragging?'var(--accent-light)':'var(--surface2)',
cursor:'pointer',transition:'all 0.15s'}}>
<Upload size={24} style={{color:dragging?'var(--accent)':'var(--text3)',marginBottom:6}}/>
<div style={{fontSize:13,color:dragging?'var(--accent-dark)':'var(--text2)'}}>
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
</div>
</div>
{status==='loading' && (
<div style={{marginTop:8,display:'flex',gap:8,fontSize:13,color:'var(--text2)'}}>
<div className="spinner" style={{width:14,height:14}}/> Importiere
</div>
)}
{error && <div style={{marginTop:8,padding:'8px 12px',background:'#FCEBEB',borderRadius:8,fontSize:13,color:'#D85A30'}}>{error}</div>}
{status && status!=='loading' && (
<div style={{marginTop:8,padding:'10px 12px',background:'var(--accent-light)',borderRadius:8,fontSize:13,color:'var(--accent-dark)'}}>
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
<CheckCircle size={14}/><strong>Import erfolgreich</strong>
</div>
<div>{status.inserted} Trainings importiert · {status.skipped} übersprungen</div>
</div>
)}
</div>
)
}
// ── Manual Entry ──────────────────────────────────────────────────────────────
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
<div className="form-row">
<label className="form-label">Datum</label>
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Trainingsart</label>
<select className="form-select" value={form.activity_type} onChange={e=>set('activity_type',e.target.value)}>
{ACTIVITY_TYPES.map(t=><option key={t} value={t}>{t}</option>)}
</select>
</div>
<div className="form-row">
<label className="form-label">Dauer</label>
<input type="number" className="form-input" min={1} max={600} step={1}
placeholder="" value={form.duration_min||''} onChange={e=>set('duration_min',e.target.value)}/>
<span className="form-unit">Min</span>
</div>
<div className="form-row">
<label className="form-label">Kcal (aktiv)</label>
<input type="number" className="form-input" min={0} max={5000} step={1}
placeholder="" value={form.kcal_active||''} onChange={e=>set('kcal_active',e.target.value)}/>
<span className="form-unit">kcal</span>
</div>
<div className="form-row">
<label className="form-label">HF Ø</label>
<input type="number" className="form-input" min={40} max={220} step={1}
placeholder="" value={form.hr_avg||''} onChange={e=>set('hr_avg',e.target.value)}/>
<span className="form-unit">bpm</span>
</div>
<div className="form-row">
<label className="form-label">HF Max</label>
<input type="number" className="form-input" min={40} max={220} step={1}
placeholder="" value={form.hr_max||''} onChange={e=>set('hr_max',e.target.value)}/>
<span className="form-unit">bpm</span>
</div>
<div className="form-row">
<label className="form-label">Intensität</label>
<input type="number" className="form-input" min={1} max={10} step={1}
placeholder="110" value={form.rpe||''} onChange={e=>set('rpe',e.target.value)}/>
<span className="form-unit">RPE</span>
</div>
<div className="form-row">
<label className="form-label">Notiz</label>
<input type="text" className="form-input" placeholder="optional"
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
</div>
)}
<div style={{display:'flex',gap:6,marginTop:8}}>
<div
title={usage && !usage.allowed ? `Limit erreicht (${usage.used}/${usage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{flex:1,display:'inline-block'}}
>
<button
className="btn btn-primary"
style={{width:'100%', cursor: (usage && !usage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={onSave}
disabled={saving || (usage && !usage.allowed)}
>
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
</button>
</div>
{onCancel && <button className="btn btn-secondary" style={{flex:1}} onClick={onCancel}><X size={13}/> Abbrechen</button>}
</div>
</div>
)
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export default function ActivityPage() {
const [entries, setEntries] = useState([])
const [stats, setStats] = useState(null)
const [tab, setTab] = useState('list')
const [form, setForm] = useState(empty())
const [editing, setEditing] = useState(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s)
}
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
setActivityUsage(activityFeature)
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
loadUsage()
},[])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = {...form}
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
setSaved(true)
await load()
await loadUsage() // Reload usage after save
setTimeout(()=>{ setSaved(false); setForm(empty()) }, 1500)
} catch (err) {
console.error('Save failed:', err)
setError(err.message || 'Fehler beim Speichern')
setTimeout(()=>setError(null), 5000)
} finally {
setSaving(false)
}
}
const handleUpdate = async () => {
const payload = {...editing}
await api.updateActivity(editing.id, payload)
setEditing(null); await load()
}
const handleDelete = async (id) => {
if(!confirm('Training löschen?')) return
await api.deleteActivity(id); await load()
}
// Chart data: kcal per day (last 30 days)
const chartData = (() => {
const byDate = {}
entries.forEach(e=>{
byDate[e.date] = (byDate[e.date]||0) + (e.kcal_active||0)
})
return Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).slice(-30).map(([date,kcal])=>({
date: dayjs(date).format('DD.MM'), kcal: Math.round(kcal)
}))
})()
const TYPE_COLORS = {
'Traditionelles Krafttraining':'#1D9E75','Matrial Arts':'#D85A30',
'Outdoor Spaziergang':'#378ADD','Innenräume Spaziergang':'#7F77DD',
'Laufen':'#EF9F27','Radfahren':'#D4537E','Sonstiges':'#888780'
}
return (
<div>
<h1 className="page-title">Aktivität</h1>
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>
<button className={'tab'+(tab==='add'?' active':'')} onClick={()=>setTab('add')}>+ Manuell</button>
<button className={'tab'+(tab==='import'?' active':'')} onClick={()=>setTab('import')}>Import</button>
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
</div>
{/* Übersicht */}
{stats && stats.count>0 && (
<div className="card section-gap">
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
{[['Trainings',stats.count,'var(--text1)'],
['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'],
['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>(
<div key={l} style={{flex:1,minWidth:80,background:'var(--surface2)',borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
<div style={{fontSize:18,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
</div>
)}
{tab==='import' && <ImportPanel onImported={load}/>}
{tab==='add' && (
<div className="card section-gap">
<div className="card-title badge-container-right">
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={activityUsage}/>
</div>
)}
{tab==='stats' && stats && (
<div>
{chartData.length>=2 && (
<div className="card section-gap">
<div className="card-title">Aktive Kalorien pro Tag</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={chartData} 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(chartData.length/6)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={v=>[`${v} kcal`,'Aktiv']}/>
<Bar dataKey="kcal" fill="#EF9F27" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="card section-gap">
<div className="card-title">Nach Trainingsart</div>
{Object.entries(stats.by_type).sort((a,b)=>b[1].kcal-a[1].kcal).map(([type,data])=>(
<div key={type} style={{display:'flex',alignItems:'center',gap:10,padding:'6px 0',borderBottom:'1px solid var(--border)'}}>
<div style={{width:10,height:10,borderRadius:2,background:TYPE_COLORS[type]||'#888',flexShrink:0}}/>
<div style={{flex:1,fontSize:13}}>{type}</div>
<div style={{fontSize:12,color:'var(--text3)'}}>{data.count}× · {Math.round(data.min)} Min · {Math.round(data.kcal)} kcal</div>
</div>
))}
</div>
</div>
)}
{tab==='list' && (
<div>
{entries.length===0 && (
<div className="empty-state">
<h3>Keine Trainings</h3>
<p>Importiere deine Apple Health Daten oder trage manuell ein.</p>
</div>
)}
{entries.map(e=>{
const isEd = editing?.id===e.id
const color = TYPE_COLORS[e.activity_type]||'#888'
return (
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
{isEd ? (
<EntryForm form={editing} setForm={setEditing}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
<div style={{flex:1}}>
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
</div>
<div style={{display:'flex',gap:10,flexWrap:'wrap'}}>
{e.duration_min && <span style={{fontSize:12,color:'var(--text2)'}}> {Math.round(e.duration_min)} Min</span>}
{e.kcal_active && <span style={{fontSize:12,color:'#EF9F27'}}>🔥 {Math.round(e.kcal_active)} kcal</span>}
{e.hr_avg && <span style={{fontSize:12,color:'var(--text2)'}}> Ø{Math.round(e.hr_avg)} bpm</span>}
{e.hr_max && <span style={{fontSize:12,color:'var(--text2)'}}>{Math.round(e.hr_max)} bpm</span>}
{e.distance_km && e.distance_km>0 && <span style={{fontSize:12,color:'var(--text2)'}}>📍 {Math.round(e.distance_km*10)/10} km</span>}
{e.rpe && <span style={{fontSize:12,color:'var(--text2)'}}>RPE {e.rpe}/10</span>}
{e.source==='apple_health' && <span style={{fontSize:10,color:'var(--text3)'}}>Apple Health</span>}
</div>
{e.notes && <p style={{fontSize:12,color:'var(--text2)',fontStyle:'italic',marginTop:4}}>"{e.notes}"</p>}
</div>
<div style={{display:'flex',gap:6,marginLeft:8}}>
<button className="btn btn-secondary" style={{padding:'5px 8px'}} onClick={()=>setEditing({...e})}><Pencil size={13}/></button>
<button className="btn btn-danger" style={{padding:'5px 8px'}} onClick={()=>handleDelete(e.id)}><Trash2 size={13}/></button>
</div>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}