import { useState, useEffect, useRef, useCallback, startTransition } 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') /** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */ const ACTIVITY_LIST_PAGE_SIZE = 40 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', 'hr_min', 'distance_km', 'pace_min_per_km', 'cadence', 'avg_power', 'elevation_gain', 'temperature_celsius', 'humidity_percent', 'avg_hr_percent', 'kcal_per_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}`) out.push({ parameter_key: s.key, value: null }) 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}`) out.push({ parameter_key: s.key, value: null }) 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 [listHasMore, setListHasMore] = useState(false) const [listLoadingMore, setListLoadingMore] = useState(false) const loadFirstPage = useCallback(async () => { const n = ACTIVITY_LIST_PAGE_SIZE const [e, s] = await Promise.all([ api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }), api.activityStats({ skipQualityFilter: true }), ]) setEntries(e) setStats(s) setListHasMore(e.length === n) }, []) const entriesRef = useRef(entries) useEffect(() => { entriesRef.current = entries }, [entries]) const loadMore = async () => { if (listLoadingMore || !listHasMore) return setListLoadingMore(true) try { const n = ACTIVITY_LIST_PAGE_SIZE const offset = entriesRef.current.length const more = await api.listActivity(n, undefined, { skipQualityFilter: true, offset, }) if (more.length === 0) { setListHasMore(false) return } const prev = entriesRef.current const seen = new Set(prev.map((r) => r.id)) const add = more.filter((r) => r.id && !seen.has(r.id)) if (add.length === 0) { setListHasMore(false) return } setEntries((p) => [...p, ...add]) setListHasMore(more.length === n) } finally { setListLoadingMore(false) } } const load = loadFirstPage 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(() => { void loadFirstPage() loadUsage() api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) }, [loadFirstPage]) 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) { payload[col] = null 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) startTransition(() => { void 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.total_in_profile > 0 || stats.count > 0) && (
{stats.total_in_profile ?? '–'} Einträge im Profil (gleicher Filter wie diese Seite). Die Summen Kcal/Stunden beziehen sich auf die neuesten {stats.sample_size ?? stats.count} Einträge (max. 30).
{[ ['Neueste (max. 30)', stats.count, 'var(--text1)'], ['Kcal (darin)', Math.round(stats.total_kcal), '#EF9F27'], ['Stunden (darin)', 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' && (

Hier sind alle Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es werden jeweils bis zu{' '} {ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen.

{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}"

}
)}
) })} {listHasMore && (
)}
)}
) }