mitai-jinkendo/frontend/src/pages/ActivityPage.jsx
Lars cc0f57758a
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: Implement sorting and categorization for activity profile schema rows
- 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.
2026-04-16 13:46:29 +02:00

1105 lines
44 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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="110" 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>
)
}