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.name_de}
{s.required ? ' *' : ''}
{s.unit ? ` (${s.unit})` : ''}
{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.key}
{row.unit ? ` (${row.unit})` : ''}
{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 (
Datum
set('date',e.target.value)}/>
Start (Uhrzeit)
set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
/>
zum Datum oben
Ende (Uhrzeit)
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}
/>
Dauer
set('duration_min',e.target.value)}/>
Min
Kcal (aktiv)
set('kcal_active',e.target.value)}/>
kcal
HF Ø
set('hr_avg',e.target.value)}/>
bpm
HF Max
set('hr_max',e.target.value)}/>
bpm
Intensität
set('rpe',e.target.value)}/>
RPE
Notiz
set('notes',e.target.value)}/>
{formExtras}
{error && (
{error}
)}
{(usage && !usage.allowed) ? '🔒 Limit erreicht' : saveLabel}
{onCancel &&
Abbrechen }
)
}
// ── 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
setTab('list')}>Verlauf
setTab('add')}>+ Manuell
setTab('import')}>Import
setTab('categorize')}>Kategorisieren
setTab('stats')}>Statistik
{/* Ü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]) => (
))}
)}
{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' && (
Monat
{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}"
}
setEditing({...e})}>
handleDelete(e.id)}>
)}
)
})}
{canLoadOlder && (
void loadPreviousMonth()}
>
{listLoadingMore
? 'Lade…'
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
)}
)}
)
}