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 TrainingTypeSelect from '../components/TrainingTypeSelect' import BulkCategorize from '../components/BulkCategorize' 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' ] /** Spalten, die mit ActivityEntry / UPDATE activity_log geschrieben werden dürfen (Übergang: Profilfelder → Kopfzeile). */ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'date', 'start_time', 'end_time', 'activity_type', 'duration_min', 'kcal_active', 'kcal_resting', 'hr_avg', 'hr_max', 'distance_km', 'rpe', 'source', 'notes', 'training_type_id', 'training_category', 'training_subcategory', ]) function empty() { return { date: dayjs().format('YYYY-MM-DD'), activity_type: 'Traditionelles Krafttraining', duration_min: '', kcal_active: '', hr_avg: '', hr_max: '', rpe: '', notes: '', training_type_id: null, training_category: null, training_subcategory: null } } function buildMetricsPayload(schema, draft) { const out = [] for (const s of schema) { const raw = draft[s.key] if (s.data_type === 'boolean') { if (raw === '' || raw === null || raw === undefined) { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) continue } out.push({ parameter_key: s.key, value: !!raw }) continue } if (raw === '' || raw === null || raw === undefined) { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) continue } let v = raw if (s.data_type === 'integer') { v = parseInt(String(raw), 10) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'float') { v = parseFloat(String(raw)) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else { v = String(raw) } out.push({ parameter_key: s.key, value: v }) } return out } function SessionMetricsFields({ schema, values, setValues }) { if (!schema || schema.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) return (
Weitere Kennwerte (Profil)
{schema.map((s) => (
{s.data_type === 'boolean' ? ( set(s.key, e.target.checked)} /> ) : s.data_type === 'integer' || s.data_type === 'float' ? ( set(s.key, e.target.value)} /> ) : ( set(s.key, e.target.value)} /> )}
))}
) } // ── 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 (
📥 Apple Health Import

Health Auto Export App → Workouts exportieren → CSV → hier hochladen.
Nur die Workouts-…csv Datei wird benötigt (nicht die Detaildateien).

{ const f=e.target.files[0]; if(f) runImport(f); e.target.value='' }}/>
{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'}}>
{dragging?'Datei loslassen…':'CSV hierher ziehen oder tippen'}
{status==='loading' && (
Importiere…
)} {error &&
{error}
} {status && status!=='loading' && (
Import erfolgreich
{status.inserted} Trainings importiert · {status.skipped} übersprungen
)}
) } // ── Manual Entry ────────────────────────────────────────────────────────────── function EntryForm({ form, setForm, onSave, onCancel, saveLabel = 'Speichern', saving = false, error = null, usage = null, formExtras = null, }) { const set = (k,v) => setForm(f=>({...f,[k]:v})) return (
set('date',e.target.value)}/>
{ setForm(f => ({ ...f, training_type_id: typeId, training_category: category, training_subcategory: subcategory })) }} required={false} />
set('duration_min',e.target.value)}/> Min
set('kcal_active',e.target.value)}/> kcal
set('hr_avg',e.target.value)}/> bpm
set('hr_max',e.target.value)}/> bpm
set('rpe',e.target.value)}/> RPE
set('notes',e.target.value)}/>
{formExtras} {error && (
{error}
)}
{onCancel && }
) } // ── 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 [categories, setCategories] = useState({}) // v9d: Training categories const [sessionDetail, setSessionDetail] = useState(null) const [metricDraft, setMetricDraft] = useState({}) const [sessionLoadError, setSessionLoadError] = useState(null) const [savingEdit, setSavingEdit] = useState(false) 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() api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) },[]) useEffect(() => { if (!editing?.id) { setSessionDetail(null) setMetricDraft({}) setSessionLoadError(null) return } let cancelled = false setSessionLoadError(null) ;(async () => { try { const d = await api.getActivitySession(editing.id) if (!cancelled) setSessionDetail(d) } catch (err) { if (!cancelled) { setSessionDetail(null) setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden') } } })() return () => { cancelled = true } }, [editing?.id]) useEffect(() => { if (!sessionDetail) { setMetricDraft({}) return } const m = {} for (const row of sessionDetail.metrics || []) { m[row.key] = row.value } for (const s of sessionDetail.schema || []) { if (!(s.key in m)) { m[s.key] = s.data_type === 'boolean' ? false : '' } } setMetricDraft(m) }, [sessionDetail]) 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 () => { setSavingEdit(true) setError(null) try { const payload = { ...editing } delete payload.id for (const s of sessionDetail?.schema || []) { const col = s.source_field if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue if (!(s.key in metricDraft)) continue const raw = metricDraft[s.key] if (raw === '' || raw === null || raw === undefined) continue let v = raw if (s.data_type === 'integer') { v = parseInt(String(raw), 10) if (Number.isNaN(v)) continue } else if (s.data_type === 'float') { v = parseFloat(String(raw)) if (Number.isNaN(v)) continue } else if (s.data_type === 'boolean') { v = !!raw } else { v = String(raw) } payload[col] = v } if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min) if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active) if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg) if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max) if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10) await api.updateActivity(editing.id, payload) if (sessionDetail?.schema?.length > 0) { const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft) await api.putActivityMetrics(editing.id, { metrics }) } setEditing(null) setSessionDetail(null) await load() } catch (err) { setError(err.message || 'Speichern fehlgeschlagen') setTimeout(() => setError(null), 6000) } finally { setSavingEdit(false) } } 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 (

Aktivität

{/* Übersicht */} {stats && stats.count>0 && (
{[['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])=>(
{v}
{l}
))}
)} {tab==='import' && } {tab==='categorize' && (
🏷️ Aktivitäten kategorisieren
{ load(); setTab('list'); }} />
)} {tab==='add' && (
Training eintragen {activityUsage && }
)} {tab==='stats' && stats && (
{chartData.length>=2 && (
Aktive Kalorien pro Tag
[`${v} kcal`,'Aktiv']}/>
)}
Nach Trainingsart
{Object.entries(stats.by_type).sort((a,b)=>b[1].kcal-a[1].kcal).map(([type,data])=>(
{type}
{data.count}× · {Math.round(data.min)} Min · {Math.round(data.kcal)} kcal
))}
)} {tab==='list' && (
{entries.length===0 && (

Keine Trainings

Importiere deine Apple Health Daten oder trage manuell ein.

)} {entries.map(e=>{ const isEd = editing?.id===e.id const color = TYPE_COLORS[e.activity_type]||'#888' return (
{isEd ? ( { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }} saveLabel="Speichern" saving={savingEdit} error={error} formExtras={ <> {sessionLoadError && (
{sessionLoadError}
)} } /> ) : (
{/* Evaluation Status Indicator */} {e.quality_label ? (
✓
) : e.training_type_id ? (
⚠
) : (
✕
)}
{e.activity_type}
{e.training_category && categories[e.training_category] && (
{categories[e.training_category].icon} {categories[e.training_category].name_de}
)}
{dayjs(e.date).format('dd, DD. MMMM YYYY')} {e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
{e.duration_min && ⏱ {Math.round(e.duration_min)} Min} {e.kcal_active && 🔥 {Math.round(e.kcal_active)} kcal} {e.hr_avg && ❤️ Ø{Math.round(e.hr_avg)} bpm} {e.hr_max && ↑{Math.round(e.hr_max)} bpm} {e.distance_km && e.distance_km>0 && 📍 {Math.round(e.distance_km*10)/10} km} {e.rpe && RPE {e.rpe}/10} {e.source==='apple_health' && Apple Health}
{e.notes &&

"{e.notes}"

}
)}
) })}
)}
) }