diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md index c0581bf..186f46c 100644 --- a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -81,8 +81,8 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a ## 5. Agent-Checkliste (nächste Iterationen) -- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter. -- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`. +- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“. +- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`. - [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor). - [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`. - [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue). diff --git a/CLAUDE.md b/CLAUDE.md index 0de72bb..014823a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,7 @@ frontend/src/ - **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`). - **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable). - **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt). +- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt. ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f9a5fd2..a5b44a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' +import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage' import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' @@ -255,6 +256,7 @@ function AppShell() { } /> }/> }/> + } /> }/> }/> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index d87cf5c..848306f 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [ label: 'Trainings-Profile', description: 'Training-Type-Profile (#15).', }, + { + to: '/admin/activity-attribute-profiles', + label: 'Session-Metriken (EAV)', + description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.', + }, ], }, { diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 759bdf2..e68054b 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -27,6 +27,80 @@ function empty() { } } +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}`) + continue + } + out.push({ parameter_key: s.key, value: !!raw }) + continue + } + if (raw === '' || raw === null || raw === undefined) { + if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) + continue + } + let v = raw + if (s.data_type === 'integer') { + v = parseInt(String(raw), 10) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) + } else if (s.data_type === 'float') { + v = parseFloat(String(raw)) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) + } else { + v = String(raw) + } + out.push({ parameter_key: s.key, value: v }) + } + return out +} + +function SessionMetricsFields({ schema, values, setValues }) { + if (!schema || schema.length === 0) return null + const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) + return ( +
+
Weitere Kennwerte (Profil)
+ {schema.map((s) => ( +
+ + {s.data_type === 'boolean' ? ( + set(s.key, e.target.checked)} + /> + ) : s.data_type === 'integer' || s.data_type === 'float' ? ( + set(s.key, e.target.value)} + /> + ) : ( + set(s.key, e.target.value)} + /> + )} + +
+ ))} +
+ ) +} + // ── Import Panel ────────────────────────────────────────────────────────────── function ImportPanel({ onImported }) { const fileRef = useRef() @@ -85,7 +159,17 @@ function ImportPanel({ onImported }) { } // ── Manual Entry ────────────────────────────────────────────────────────────── -function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) { +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 (
@@ -144,6 +228,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
+ {formExtras} {error && (
{error} @@ -181,6 +266,10 @@ export default function ActivityPage() { 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 load = async () => { const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) @@ -200,6 +289,46 @@ export default function ActivityPage() { api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) },[]) + 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) @@ -226,9 +355,30 @@ export default function ActivityPage() { } const handleUpdate = async () => { - const payload = {...editing} - await api.updateActivity(editing.id, payload) - setEditing(null); await load() + setSavingEdit(true) + setError(null) + try { + const payload = { ...editing } + delete payload.id + 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) + 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) + await load() + } catch (err) { + setError(err.message || 'Speichern fehlgeschlagen') + setTimeout(() => setError(null), 6000) + } finally { + setSavingEdit(false) + } } const handleDelete = async (id) => { @@ -347,8 +497,27 @@ export default function ActivityPage() { return (
{isEd ? ( - setEditing(null)} saveLabel="Speichern"/> + { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }} + saveLabel="Speichern" + saving={savingEdit} + error={error} + formExtras={ + <> + {sessionLoadError && ( +
{sessionLoadError}
+ )} + + + } + /> ) : (
diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx new file mode 100644 index 0000000..3fae2b4 --- /dev/null +++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx @@ -0,0 +1,594 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { Plus, Trash2, Save, RefreshCw } from 'lucide-react' +import { api } from '../utils/api' + +const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance'] +const DATA_TYPES = ['integer', 'float', 'string', 'boolean'] + +const emptyParamForm = () => ({ + key: '', + name_de: '', + name_en: '', + category: 'physical', + data_type: 'float', + unit: '', + source_field: '', + is_active: true, +}) + +export default function AdminActivityAttributeProfilesPage() { + const [params, setParams] = useState([]) + const [includeInactive, setIncludeInactive] = useState(false) + const [catMeta, setCatMeta] = useState({}) + const [flatTypes, setFlatTypes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [toast, setToast] = useState(null) + + const [showParamForm, setShowParamForm] = useState(false) + const [paramForm, setParamForm] = useState(emptyParamForm()) + + const [selCategory, setSelCategory] = useState('cardio') + const [catLinks, setCatLinks] = useState([]) + const [catAdd, setCatAdd] = useState({ + training_parameter_id: '', + sort_order: 0, + required: false, + ui_group: '', + }) + + const [selTypeId, setSelTypeId] = useState('') + const [typeLinks, setTypeLinks] = useState([]) + const [typeAdd, setTypeAdd] = useState({ + training_parameter_id: '', + sort_order: '', + required: '', + ui_group: '', + }) + + const showToast = (msg) => { + setToast(msg) + setTimeout(() => setToast(null), 2800) + } + + const refreshCatalog = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [p, cats, flat] = await Promise.all([ + api.adminListTrainingParameters(includeInactive), + api.getTrainingCategories(), + api.listTrainingTypesFlat(), + ]) + setParams(Array.isArray(p) ? p : []) + setCatMeta(cats && typeof cats === 'object' ? cats : {}) + setFlatTypes(Array.isArray(flat) ? flat : []) + setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : '')) + } catch (e) { + setError(e.message || 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, [includeInactive]) + + useEffect(() => { + refreshCatalog() + }, [refreshCatalog]) + + const loadCatLinks = useCallback(async () => { + try { + const data = await api.adminListTrainingCategoryParameters(selCategory) + setCatLinks(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Kategorie-Zuordnungen') + } + }, [selCategory]) + + useEffect(() => { + loadCatLinks() + }, [loadCatLinks]) + + const loadTypeLinks = useCallback(async () => { + if (!selTypeId) { + setTypeLinks([]) + return + } + try { + const data = await api.adminListTrainingTypeParameters(Number(selTypeId)) + setTypeLinks(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Typ-Zuordnungen') + } + }, [selTypeId]) + + useEffect(() => { + loadTypeLinks() + }, [loadTypeLinks]) + + const activeParams = params.filter((p) => p.is_active !== false) + + const saveNewParameter = async () => { + setError(null) + if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) { + setError('key, name_de und name_en sind Pflicht.') + return + } + try { + await api.adminCreateTrainingParameter({ + key: paramForm.key.trim().toLowerCase(), + name_de: paramForm.name_de.trim(), + name_en: paramForm.name_en.trim(), + category: paramForm.category, + data_type: paramForm.data_type, + unit: paramForm.unit.trim() || null, + source_field: paramForm.source_field.trim() || null, + is_active: paramForm.is_active, + validation_rules: {}, + }) + showToast('Parameter angelegt') + setShowParamForm(false) + setParamForm(emptyParamForm()) + await refreshCatalog() + } catch (e) { + setError(e.message || 'Speichern fehlgeschlagen') + } + } + + const deactivateParameter = async (id) => { + if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return + try { + await api.adminDeleteTrainingParameter(id) + showToast('Deaktiviert') + await refreshCatalog() + } catch (e) { + setError(e.message || 'Löschen fehlgeschlagen') + } + } + + const addCatLink = async () => { + const pid = parseInt(catAdd.training_parameter_id, 10) + if (!pid) { + setError('Parameter-ID wählen') + return + } + try { + await api.adminAddTrainingCategoryParameter({ + training_category: selCategory, + training_parameter_id: pid, + sort_order: Number(catAdd.sort_order) || 0, + required: !!catAdd.required, + ui_group: catAdd.ui_group.trim() || null, + }) + showToast('Zuordnung gespeichert') + setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' }) + await loadCatLinks() + } catch (e) { + setError(e.message || 'Konflikt oder ungültige Daten') + } + } + + const addTypeLink = async () => { + const tid = Number(selTypeId) + const pid = parseInt(typeAdd.training_parameter_id, 10) + if (!tid || !pid) { + setError('Trainingstyp und Parameter wählen') + return + } + const body = { + training_type_id: tid, + training_parameter_id: pid, + sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order), + required: typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true, + ui_group: typeAdd.ui_group.trim() || null, + } + try { + await api.adminAddTrainingTypeParameter(body) + showToast('Typ-Zuordnung gespeichert') + setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' }) + await loadTypeLinks() + } catch (e) { + setError(e.message || 'Konflikt oder ungültige Daten') + } + } + + const categoryKeys = + Object.keys(catMeta).length > 0 + ? Object.keys(catMeta).sort() + : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other'] + + if (loading && !params.length) { + return ( +
+
Lade… +
+ ) + } + + return ( +
+
+ + ← Training (Hub) + +
+

Session-Metriken (EAV)

+

+ Messgrößen-Katalog und Zuordnung zu Kategorie (Basis) bzw.{' '} + Trainingstyp (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer + Aktivität, wenn der Eintrag passend kategorisiert ist. +

+ + {toast && ( +
+ {toast} +
+ )} + {error && ( +
+ {error} + +
+ )} + +
+
+ Parameter-Katalog + + + +
+ + {showParamForm && ( +
+
+ + setParamForm((f) => ({ ...f, key: e.target.value }))} + /> +
+
+ + setParamForm((f) => ({ ...f, name_de: e.target.value }))} + placeholder="DE" + /> + setParamForm((f) => ({ ...f, name_en: e.target.value }))} + placeholder="EN" + /> +
+
+ + + +
+
+ + setParamForm((f) => ({ ...f, unit: e.target.value }))} + /> + setParamForm((f) => ({ ...f, source_field: e.target.value }))} + /> +
+
+ + +
+
+ )} + +
+ + + + + + + + + + + + {params.map((r) => ( + + + + + + + + + ))} + +
IDkeyDETypaktiv +
{r.id} + {r.key} + {r.name_de} + {r.data_type} · {r.category} + {r.is_active === false ? 'nein' : 'ja'} + {r.is_active !== false && ( + + )} +
+
+
+ +
+
Zuordnung: Trainings-Kategorie
+
+ + +
+
+
+ + +
+
+ + setCatAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+ +
+ + setCatAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+ +
+
    + {catLinks.map((l) => ( +
  • + + {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order} + {l.required ? ' · Pflicht' : ''} + {l.ui_group ? ` · ${l.ui_group}` : ''} + + +
  • + ))} +
+
+ +
+
Zuordnung: Trainingstyp (Zusatz / Override)
+
+ + +
+
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+ +
+
    + {typeLinks.map((l) => ( +
  • + + {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '} + {l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'} + + +
  • + ))} +
+
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 205acc2..f09d55c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -314,6 +314,30 @@ export const api = { adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}), adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'), + // Admin: Training session metrics (EAV) & attribute profiles (Migration 054) + adminListTrainingParameters: (includeInactive = false) => + req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`), + adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)), + adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)), + adminDeleteTrainingParameter: (id) => + req(`/admin/training-parameters/${id}`, { method: 'DELETE' }), + adminListTrainingCategoryParameters: (category = '') => + req( + `/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`, + ), + adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)), + adminDeleteTrainingCategoryParameter: (id) => + req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }), + adminListTrainingTypeParameters: (trainingTypeId) => + req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`), + adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)), + adminDeleteTrainingTypeParameter: (id) => + req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }), + + getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`), + putActivityMetrics: (id, body) => + req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)), + // Sleep Module (v9d Phase 2b) listSleep: (l=90) => req(`/sleep?limit=${l}`), getSleepByDate: (date) => req(`/sleep/by-date/${date}`),