- Introduced a new sorting mechanism for activity profile schema rows based on defined categories and UI groups, enhancing the organization of displayed metrics. - Added constants for training parameter categories and their German labels to improve clarity in the UI. - Refactored the `SessionMetricsFields` component to utilize the new sorting logic, replacing the previous mapping approach for better maintainability and user experience. - Ensured that orphan metrics are sorted correctly for consistent display alongside the main metrics.
1105 lines
44 KiB
JavaScript
1105 lines
44 KiB
JavaScript
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(
|
||
<div
|
||
key={`prof-cat-${catKey}-${profileFieldNodes.length}`}
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 700,
|
||
color: 'var(--text2)',
|
||
marginTop: profileFieldNodes.length ? 14 : 6,
|
||
marginBottom: 6,
|
||
letterSpacing: '0.02em',
|
||
}}
|
||
>
|
||
{catTitle}
|
||
</div>,
|
||
)
|
||
}
|
||
const ug = (s.ui_group && String(s.ui_group).trim()) || ''
|
||
if (ug) {
|
||
if (ug !== lastUiGroup) {
|
||
lastUiGroup = ug
|
||
profileFieldNodes.push(
|
||
<div
|
||
key={`prof-ug-${catKey}-${ug}-${profileFieldNodes.length}`}
|
||
style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 4 }}
|
||
>
|
||
{ug}
|
||
</div>,
|
||
)
|
||
}
|
||
} else {
|
||
lastUiGroup = null
|
||
}
|
||
profileFieldNodes.push(
|
||
<div key={s.key} className="form-row">
|
||
<label className="form-label">
|
||
{s.name_de}
|
||
{s.required ? ' *' : ''}
|
||
{s.unit ? ` (${s.unit})` : ''}
|
||
</label>
|
||
{s.data_type === 'boolean' ? (
|
||
<input
|
||
type="checkbox"
|
||
style={{ width: 'auto', marginRight: 'auto' }}
|
||
checked={!!values[s.key]}
|
||
onChange={(e) => set(s.key, e.target.checked)}
|
||
/>
|
||
) : s.data_type === 'integer' || s.data_type === 'float' ? (
|
||
<input
|
||
type="number"
|
||
className="form-input"
|
||
step={s.data_type === 'integer' ? 1 : 'any'}
|
||
value={values[s.key] ?? ''}
|
||
onChange={(e) => set(s.key, e.target.value)}
|
||
/>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
className="form-input"
|
||
value={values[s.key] ?? ''}
|
||
onChange={(e) => set(s.key, e.target.value)}
|
||
/>
|
||
)}
|
||
<span className="form-unit" />
|
||
</div>,
|
||
)
|
||
}
|
||
|
||
const orphansSorted = [...orphanMetrics].sort((a, b) =>
|
||
String(a.key).localeCompare(String(b.key), 'de'),
|
||
)
|
||
|
||
return (
|
||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||
{profileFieldNodes}
|
||
{orphanMetrics.length > 0 && (
|
||
<div style={{ marginTop: 14 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||
Werte aus Import/älteren Daten, die zum <strong>aktuellen</strong> 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.
|
||
</div>
|
||
{orphansSorted.map((row) => {
|
||
const disp =
|
||
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
|
||
? '—'
|
||
: String(values[row.key])
|
||
return (
|
||
<div key={row.key} className="form-row">
|
||
<label className="form-label">
|
||
{row.key}
|
||
{row.unit ? ` (${row.unit})` : ''}
|
||
</label>
|
||
{row.data_type === 'boolean' ? (
|
||
<input type="checkbox" style={{ width: 'auto', marginRight: 'auto' }} checked={!!values[row.key]} readOnly disabled />
|
||
) : (
|
||
<div
|
||
className="form-input"
|
||
style={{
|
||
background: 'var(--surface2)',
|
||
cursor: 'default',
|
||
color: 'var(--text1)',
|
||
}}
|
||
>
|
||
{disp}
|
||
</div>
|
||
)}
|
||
<span className="form-unit" />
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 ──────────────────────────────────────────────────────────────
|
||
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 (
|
||
<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>
|
||
<div className="form-row">
|
||
<label className="form-label">Start (Uhrzeit)</label>
|
||
<input
|
||
type="time"
|
||
step={1}
|
||
className="form-input"
|
||
style={{ width: 'auto', minWidth: 140 }}
|
||
value={timeInputValueFromApi(form.start_time)}
|
||
onChange={(e) => set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
|
||
/>
|
||
<span className="form-unit">zum Datum oben</span>
|
||
</div>
|
||
<div className="form-row">
|
||
<label className="form-label">Ende (Uhrzeit)</label>
|
||
<input
|
||
type="time"
|
||
step={1}
|
||
className="form-input"
|
||
style={{ width: 'auto', minWidth: 140 }}
|
||
value={timeInputValueFromApi(form.end_time)}
|
||
onChange={(e) => set('end_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
|
||
/>
|
||
<span className="form-unit">optional</span>
|
||
</div>
|
||
<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}
|
||
/>
|
||
</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>
|
||
{formExtras}
|
||
{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>
|
||
)}
|
||
<div style={{display:'flex',gap:6,marginTop:8}}>
|
||
<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>
|
||
{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)
|
||
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 (
|
||
<div className="capture-page">
|
||
<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>
|
||
<button className={'tab'+(tab==='categorize'?' active':'')} onClick={()=>setTab('categorize')}>Kategorisieren</button>
|
||
<button className={'tab'+(tab==='stats'?' active':'')} onClick={()=>setTab('stats')}>Statistik</button>
|
||
</div>
|
||
|
||
{/* Übersicht */}
|
||
{stats && (stats.total_in_profile > 0 || stats.count > 0) && (
|
||
<div className="card section-gap">
|
||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||
<strong>{stats.total_in_profile ?? '–'}</strong> Einträge im Profil (gleicher Filter wie diese Seite). Die Summen
|
||
Kcal/Stunden beziehen sich auf die <strong>neuesten {stats.sample_size ?? stats.count}</strong> Einträge (max.
|
||
30).
|
||
</div>
|
||
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
|
||
{[
|
||
['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]) => (
|
||
<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}/>}
|
||
|
||
{tab==='categorize' && (
|
||
<div className="card section-gap">
|
||
<div className="card-title">🏷️ Aktivitäten kategorisieren</div>
|
||
<BulkCategorize onComplete={() => { load(); setTab('list'); }} />
|
||
</div>
|
||
)}
|
||
|
||
{tab==='add' && (
|
||
<div className="card section-gap">
|
||
<div className="card-title badge-container-right">
|
||
<span>Training eintragen</span>
|
||
{activityUsage && <UsageBadge {...activityUsage} />}
|
||
</div>
|
||
<EntryForm form={form} setForm={setForm}
|
||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||
saving={saving} error={error} usage={activityUsage}/>
|
||
</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>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 12,
|
||
marginBottom: 12,
|
||
flexWrap: 'wrap',
|
||
}}
|
||
>
|
||
<label className="form-label" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
Monat
|
||
<input
|
||
type="month"
|
||
className="form-input"
|
||
value={selectedMonth}
|
||
onChange={onMonthPickerChange}
|
||
style={{ width: 'auto', minWidth: 150, margin: 0 }}
|
||
/>
|
||
</label>
|
||
{monthsIncluded.length > 1 && (
|
||
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||
Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
|
||
{dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||
Hier sind <strong>alle</strong> 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).
|
||
</p>
|
||
{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); setSessionDetail(null); setSessionLoadError(null) }}
|
||
saveLabel="Speichern"
|
||
saving={savingEdit}
|
||
error={error}
|
||
formExtras={
|
||
<>
|
||
{sessionLoadError && (
|
||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>{sessionLoadError}</div>
|
||
)}
|
||
<SessionMetricsFields
|
||
schema={sessionDetail?.schema}
|
||
metrics={sessionDetail?.metrics}
|
||
values={metricDraft}
|
||
setValues={setMetricDraft}
|
||
/>
|
||
</>
|
||
}
|
||
/>
|
||
) : (
|
||
<div>
|
||
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
|
||
<div style={{flex:1}}>
|
||
<div style={{display:'flex',alignItems:'center',gap:6,marginBottom:2}}>
|
||
{/* Evaluation Status Indicator */}
|
||
{e.quality_label ? (
|
||
<div
|
||
style={{
|
||
display:'inline-flex',
|
||
alignItems:'center',
|
||
justifyContent:'center',
|
||
width:18,
|
||
height:18,
|
||
borderRadius:9,
|
||
background: e.quality_label === 'excellent' || e.quality_label === 'good' ? '#1D9E75' :
|
||
e.quality_label === 'acceptable' ? '#EF9F27' : '#D85A30',
|
||
color:'white',
|
||
fontSize:10,
|
||
fontWeight:700,
|
||
flexShrink:0
|
||
}}
|
||
title={`Evaluation: ${e.quality_label} (Score: ${e.overall_score || 'n/a'})`}
|
||
>
|
||
✓
|
||
</div>
|
||
) : e.training_type_id ? (
|
||
<div
|
||
style={{
|
||
display:'inline-flex',
|
||
alignItems:'center',
|
||
justifyContent:'center',
|
||
width:18,
|
||
height:18,
|
||
borderRadius:9,
|
||
background:'#EF9F27',
|
||
color:'white',
|
||
fontSize:10,
|
||
fontWeight:700,
|
||
flexShrink:0
|
||
}}
|
||
title="Trainingstyp zugeordnet, aber nicht evaluiert (kein Profil konfiguriert)"
|
||
>
|
||
⚠
|
||
</div>
|
||
) : (
|
||
<div
|
||
style={{
|
||
display:'inline-flex',
|
||
alignItems:'center',
|
||
justifyContent:'center',
|
||
width:18,
|
||
height:18,
|
||
borderRadius:9,
|
||
background:'#888780',
|
||
color:'white',
|
||
fontSize:10,
|
||
fontWeight:700,
|
||
flexShrink:0
|
||
}}
|
||
title="Kein Trainingstyp zugeordnet"
|
||
>
|
||
✕
|
||
</div>
|
||
)}
|
||
<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>
|
||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
|
||
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
|
||
{(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && (
|
||
<span>
|
||
{formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`}
|
||
{formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`}
|
||
</span>
|
||
)}
|
||
</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>
|
||
)
|
||
})}
|
||
{canLoadOlder && (
|
||
<div style={{ marginTop: 12, marginBottom: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="btn btn-secondary"
|
||
style={{ width: '100%' }}
|
||
disabled={listLoadingMore}
|
||
onClick={() => void loadPreviousMonth()}
|
||
>
|
||
{listLoadingMore
|
||
? 'Lade…'
|
||
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|