mitai-jinkendo/frontend/src/pages/ActivityPage.jsx
Lars 58ddde6b1e
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s
feat: Add time input fields for activity log and enhance time handling
- Introduced start_time and end_time fields in the activity log entry form, allowing users to input specific times for activities.
- Implemented utility functions to format and validate time inputs from the API and user input, ensuring proper handling of time data.
- Updated the activity display to show formatted start and end times, improving clarity for users reviewing their activity logs.
2026-04-15 11:39:39 +02:00

953 lines
39 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',
])
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 metricRows = Array.isArray(metrics) ? metrics : []
const schemaKeys = new Set(schemaList.map((s) => s.key))
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key))
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
return (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
{schemaList.map((s) => (
<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>
))}
{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>
{orphanMetrics.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 metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
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>
)
}