2026-03-16 13:35:11 +01:00
|
|
|
|
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'
|
2026-03-21 07:42:50 +01:00
|
|
|
|
import UsageBadge from '../components/UsageBadge'
|
2026-03-21 14:56:11 +01:00
|
|
|
|
import TrainingTypeSelect from '../components/TrainingTypeSelect'
|
2026-03-21 15:08:18 +01:00
|
|
|
|
import BulkCategorize from '../components/BulkCategorize'
|
2026-03-16 13:35:11 +01:00
|
|
|
|
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: '',
|
2026-03-21 14:56:11 +01:00
|
|
|
|
hr_avg: '', hr_max: '', rpe: '', notes: '',
|
|
|
|
|
|
training_type_id: null,
|
|
|
|
|
|
training_category: null,
|
|
|
|
|
|
training_subcategory: null
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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 ──────────────────────────────────────────────────────────────
|
2026-03-21 07:42:50 +01:00
|
|
|
|
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
|
2026-03-16 13:35:11 +01:00
|
|
|
|
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>
|
2026-03-21 14:56:11 +01:00
|
|
|
|
<div style={{marginBottom:12}}>
|
|
|
|
|
|
<TrainingTypeSelect
|
|
|
|
|
|
value={form.training_type_id}
|
|
|
|
|
|
onChange={(typeId, category, subcategory) => {
|
|
|
|
|
|
setForm(f => ({
|
|
|
|
|
|
...f,
|
|
|
|
|
|
training_type_id: typeId,
|
|
|
|
|
|
training_category: category,
|
|
|
|
|
|
training_subcategory: subcategory
|
|
|
|
|
|
}))
|
|
|
|
|
|
}}
|
|
|
|
|
|
required={false}
|
|
|
|
|
|
/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</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="1–10" 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>
|
2026-03-21 07:42:50 +01:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<div style={{display:'flex',gap:6,marginTop:8}}>
|
2026-03-21 07:42:50 +01:00
|
|
|
|
<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>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{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)
|
2026-03-21 07:42:50 +01:00
|
|
|
|
const [saving, setSaving] = useState(false)
|
2026-03-16 13:35:11 +01:00
|
|
|
|
const [saved, setSaved] = useState(false)
|
2026-03-21 07:42:50 +01:00
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
|
2026-03-21 19:02:25 +01:00
|
|
|
|
const [categories, setCategories] = useState({}) // v9d: Training categories
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const load = async () => {
|
|
|
|
|
|
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
|
|
|
|
|
setEntries(e); setStats(s)
|
|
|
|
|
|
}
|
2026-03-21 07:42:50 +01:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-03-21 19:02:25 +01:00
|
|
|
|
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
2026-03-21 07:42:50 +01:00
|
|
|
|
},[])
|
2026-03-16 13:35:11 +01:00
|
|
|
|
|
|
|
|
|
|
const handleSave = async () => {
|
2026-03-21 07:42:50 +01:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-16 13:35:11 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-03-21 15:08:18 +01:00
|
|
|
|
<button className={'tab'+(tab==='categorize'?' active':'')} onClick={()=>setTab('categorize')}>Kategorisieren</button>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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}/>}
|
|
|
|
|
|
|
2026-03-21 15:08:18 +01:00
|
|
|
|
{tab==='categorize' && (
|
|
|
|
|
|
<div className="card section-gap">
|
|
|
|
|
|
<div className="card-title">🏷️ Aktivitäten kategorisieren</div>
|
|
|
|
|
|
<BulkCategorize onComplete={() => { load(); setTab('list'); }} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-16 13:35:11 +01:00
|
|
|
|
{tab==='add' && (
|
|
|
|
|
|
<div className="card section-gap">
|
2026-03-21 07:42:50 +01:00
|
|
|
|
<div className="card-title badge-container-right">
|
|
|
|
|
|
<span>Training eintragen</span>
|
|
|
|
|
|
{activityUsage && <UsageBadge {...activityUsage} />}
|
|
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<EntryForm form={form} setForm={setForm}
|
2026-03-21 07:42:50 +01:00
|
|
|
|
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
|
|
|
|
|
saving={saving} error={error} usage={activityUsage}/>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
</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}}>
|
2026-03-21 19:02:25 +01:00
|
|
|
|
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
|
|
|
|
|
|
<div style={{fontSize:14,fontWeight:600}}>{e.activity_type}</div>
|
|
|
|
|
|
{e.training_category && categories[e.training_category] && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display:'inline-flex',
|
|
|
|
|
|
alignItems:'center',
|
|
|
|
|
|
gap:3,
|
|
|
|
|
|
padding:'2px 6px',
|
|
|
|
|
|
background:categories[e.training_category].color + '22',
|
|
|
|
|
|
border:`1px solid ${categories[e.training_category].color}`,
|
|
|
|
|
|
borderRadius:4,
|
|
|
|
|
|
fontSize:10,
|
|
|
|
|
|
fontWeight:600,
|
|
|
|
|
|
color:categories[e.training_category].color
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<span>{categories[e.training_category].icon}</span>
|
|
|
|
|
|
<span>{categories[e.training_category].name_de}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-03-16 13:35:11 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|