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: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */ const ACTIVITY_MONTH_FETCH_LIMIT = 25_000 function ymdMonth(d = dayjs()) { return d.format('YYYY-MM') } function prevMonthYm(ym) { return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM') } function compareActivities(a, b) { const da = a.date || '' const db = b.date || '' if (da !== db) return db.localeCompare(da) const sa = String(a.start_time || '') const sb = String(b.start_time || '') if (sa !== sb) return sb.localeCompare(sa) return String(b.id || '').localeCompare(String(a.id || '')) } function dedupeActivitiesById(rows) { const m = new Map() for (const r of rows) { if (r?.id) m.set(r.id, r) } return [...m.values()].sort(compareActivities) } /** activity_log: Spalten start_time / end_time sind TIME (Uhrzeit zum Kalendertag date), nicht volles Timestamp. */ function timeInputValueFromApi(t) { if (t == null || t === '') return '' const s = String(t) if (s.includes('T') && s.length >= 16) return s.slice(11, 16) const m = s.match(/^(\d{1,2}):(\d{2})/) if (!m) return '' return `${m[1].padStart(2, '0')}:${m[2]}` } function timePayloadFromInput(v) { const s = v == null ? '' : String(v).trim() if (!s) return null if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00` if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s return s } function formatTimeForList(t) { const v = timeInputValueFromApi(t) return v || '' } 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', ]) /** activity_log-Spalten, die bereits in EntryForm (Kopfzeile) bearbeitet werden — Profilfeld mit gleichem source_field nicht doppelt anzeigen. */ const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([ 'duration_min', 'kcal_active', 'hr_avg', 'hr_max', 'rpe', 'notes', ]) /** * Bindung Profilparameter ↔ Kopfzeile: Entweder source_field zeigt auf eine Kopfspalte, * oder der Parameter-key ist selbst eine Kopfspalte (häufig nach Migration / ohne source_field). * @returns {{ headlineCol: string, parameterKey: string } | null} */ function activitySchemaHeadlineBinding(s) { if (!s || !s.key) return null const sf = s.source_field != null ? String(s.source_field).trim() : '' if (sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) { return { headlineCol: sf, parameterKey: s.key } } if (ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(s.key)) { return { headlineCol: s.key, parameterKey: s.key } } return null } /** training_parameters.category (siehe Migration 013); feste Reihenfolge der Wertegruppen */ const TRAINING_PARAM_CATEGORY_ORDER = [ 'physical', 'physiological', 'performance', 'subjective', 'environmental', ] const TRAINING_PARAM_CATEGORY_LABEL_DE = { physical: 'Physisch / Bewegung', physiological: 'Physiologie', performance: 'Leistung', subjective: 'Subjektiv und Wahrnehmung', environmental: 'Umwelt', } function compareActivityProfileSchemaRows(a, b) { const ca = (a.param_category && String(a.param_category).trim().toLowerCase()) || '' const cb = (b.param_category && String(b.param_category).trim().toLowerCase()) || '' const ia = TRAINING_PARAM_CATEGORY_ORDER.indexOf(ca) const ib = TRAINING_PARAM_CATEGORY_ORDER.indexOf(cb) const ra = ia === -1 ? 1000 : ia const rb = ib === -1 ? 1000 : ib if (ra !== rb) return ra - rb if (ca !== cb) return ca.localeCompare(cb, 'de') const ga = (a.ui_group && String(a.ui_group).trim()) || '' const gb = (b.ui_group && String(b.ui_group).trim()) || '' if (ga !== gb) { if (!ga) return -1 if (!gb) return 1 return ga.localeCompare(gb, 'de') } const sa = Number(a.sort_order) || 0 const sb = Number(b.sort_order) || 0 if (sa !== sb) return sa - sb return String(a.key).localeCompare(String(b.key), 'de') } function sortActivityProfileSchemaRows(rows) { return [...rows].sort(compareActivityProfileSchemaRows) } function empty() { return { date: dayjs().format('YYYY-MM-DD'), start_time: '', end_time: '', 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 } const rawStr = raw === null || raw === undefined ? '' : String(raw).trim() if (rawStr === '') { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) out.push({ parameter_key: s.key, value: null }) continue } let v if (s.data_type === 'integer') { v = parseInt(rawStr, 10) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'float') { v = parseFloat(rawStr) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else { v = rawStr } out.push({ parameter_key: s.key, value: v }) } return out } function SessionMetricsFields({ schema, values, setValues, metrics }) { const schemaList = Array.isArray(schema) ? schema : [] const headlineDuplicateKeys = new Set( schemaList.filter((s) => activitySchemaHeadlineBinding(s) != null).map((s) => s.key), ) const schemaForDisplay = schemaList.filter((s) => activitySchemaHeadlineBinding(s) == null) const metricRows = Array.isArray(metrics) ? metrics : [] const schemaKeys = new Set(schemaForDisplay.map((s) => s.key)) const orphanMetrics = metricRows.filter( (row) => row && row.key && !schemaKeys.has(row.key) && !headlineDuplicateKeys.has(row.key), ) if (schemaForDisplay.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) const sortedForDisplay = sortActivityProfileSchemaRows(schemaForDisplay) const profileFieldNodes = [] let lastCategoryKey = null let lastUiGroup = null for (const s of sortedForDisplay) { const catRaw = (s.param_category && String(s.param_category).trim().toLowerCase()) || '' const catKey = catRaw || '_other' if (catKey !== lastCategoryKey) { lastCategoryKey = catKey lastUiGroup = null const catTitle = (catRaw && TRAINING_PARAM_CATEGORY_LABEL_DE[catRaw]) || s.param_category || 'Sonstige' profileFieldNodes.push(
{catTitle}
, ) } const ug = (s.ui_group && String(s.ui_group).trim()) || '' if (ug) { if (ug !== lastUiGroup) { lastUiGroup = ug profileFieldNodes.push(
{ug}
, ) } } else { lastUiGroup = null } profileFieldNodes.push(
{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)} /> )}
, ) } const orphansSorted = [...orphanMetrics].sort((a, b) => String(a.key).localeCompare(String(b.key), 'de'), ) return (
Weitere Kennwerte (Profil)
{profileFieldNodes} {orphanMetrics.length > 0 && (
Werte aus Import/älteren Daten, die zum aktuellen Trainingsprofil dieser Session (Kategorie/Typ in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der Datenbank stehen.
{orphansSorted.map((row) => { const disp = values[row.key] === null || values[row.key] === undefined || values[row.key] === '' ? '—' : String(values[row.key]) return (
{row.data_type === 'boolean' ? ( ) : (
{disp}
)}
) })}
)}
) } // ── 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)}/>
set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')} /> zum Datum oben
set('end_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')} /> optional
{ 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 [listLoadingMore, setListLoadingMore] = useState(false) const [selectedMonth, setSelectedMonth] = useState(() => ymdMonth()) const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()]) const monthsIncludedRef = useRef(monthsIncluded) useEffect(() => { monthsIncludedRef.current = monthsIncluded }, [monthsIncluded]) const fetchMonthsChain = useCallback(async (chain) => { const lists = await Promise.all( chain.map((ym) => api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { skipQualityFilter: true, collapseDuplicateSessions: true, month: ym, }) ) ) const merged = dedupeActivitiesById(lists.flat()) const s = await api.activityStats({ skipQualityFilter: true }) setEntries(merged) setStats(s) }, []) const load = useCallback(async () => { await fetchMonthsChain(monthsIncludedRef.current) }, [fetchMonthsChain]) const onMonthPickerChange = (e) => { const ym = e.target.value if (!ym) return setSelectedMonth(ym) const chain = [ym] monthsIncludedRef.current = chain setMonthsIncluded(chain) void fetchMonthsChain(chain) } const loadPreviousMonth = async () => { const chain = monthsIncludedRef.current if (chain.length === 0) return const oldest = chain[chain.length - 1] const prev = prevMonthYm(oldest) if (chain.includes(prev)) return if (prev < '2000-01') return setListLoadingMore(true) try { const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { skipQualityFilter: true, collapseDuplicateSessions: true, month: prev, }) const newChain = [...chain, prev] monthsIncludedRef.current = newChain setMonthsIncluded(newChain) setEntries((cur) => dedupeActivitiesById([...cur, ...more])) } finally { setListLoadingMore(false) } } const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth const nextOlderYm = prevMonthYm(oldestLoadedYm) const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm) 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(() => { const ym = ymdMonth() monthsIncludedRef.current = [ym] setMonthsIncluded([ym]) setSelectedMonth(ym) void fetchMonthsChain([ym]) loadUsage() api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) }, [fetchMonthsChain]) 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} payload.start_time = payload.start_time === '' || payload.start_time == null ? null : timePayloadFromInput(payload.start_time) payload.end_time = payload.end_time === '' || payload.end_time == null ? null : timePayloadFromInput(payload.end_time) 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] const rawStr = raw === null || raw === undefined ? '' : String(raw).trim() if (rawStr === '') { payload[col] = null continue } let v = rawStr if (s.data_type === 'integer') { v = parseInt(rawStr, 10) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'float') { v = parseFloat(rawStr) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'boolean') { v = !!raw } else { v = rawStr } 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) payload.start_time = payload.start_time === '' || payload.start_time == null ? null : timePayloadFromInput(payload.start_time) payload.end_time = payload.end_time === '' || payload.end_time == null ? null : timePayloadFromInput(payload.end_time) await api.updateActivity(editing.id, payload) if (sessionDetail?.schema?.length > 0) { const draftForMetrics = { ...metricDraft } for (const s of sessionDetail.schema) { const bind = activitySchemaHeadlineBinding(s) if (!bind || !(s.key in draftForMetrics)) continue const rawCol = payload[bind.headlineCol] !== undefined ? payload[bind.headlineCol] : editing?.[bind.headlineCol] if (rawCol === undefined) continue if (s.data_type === 'boolean') { draftForMetrics[s.key] = !!rawCol } else if (s.data_type === 'integer') { const n = parseInt(String(rawCol), 10) draftForMetrics[s.key] = Number.isNaN(n) ? '' : n } else if (s.data_type === 'float') { const n = parseFloat(String(rawCol)) draftForMetrics[s.key] = Number.isNaN(n) ? '' : n } else { draftForMetrics[s.key] = rawCol == null ? '' : String(rawCol) } } const metrics = buildMetricsPayload(sessionDetail.schema, draftForMetrics) 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' && (
{monthsIncluded.length > 1 && ( Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '} {dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')} )}

Hier sind alle Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es wird jeweils ein kompletter Kalendermonat geladen; „Vorheriger Monat“ hängt den nächstälteren Monat an (ohne OFFSET-Pagination).

{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')} {(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && ( {formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`} {formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`} )}
{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}"

}
)}
) })} {canLoadOlder && (
)}
)}
) }