diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py
index e354d89..6894559 100644
--- a/backend/data_layer/activity_session_metrics.py
+++ b/backend/data_layer/activity_session_metrics.py
@@ -621,16 +621,35 @@ def replace_activity_session_metrics(
return fetch_activity_session_metrics(cur, activity_log_id)
-def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]:
+def get_activity_session_logical_unit(
+ cur,
+ profile_id: str,
+ activity_log_id: str,
+ *,
+ use_form_training_context: bool = False,
+ form_training_category: Optional[str] = None,
+ form_training_type_id: Optional[int] = None,
+) -> Dict[str, Any]:
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
if not row or str(row["profile_id"]) != str(profile_id):
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
header = dict(row)
- schema = resolve_activity_attribute_schema(
- cur, header.get("training_category"), header.get("training_type_id")
- )
+ if use_form_training_context:
+ cat = form_training_category
+ if isinstance(cat, str):
+ cat = cat.strip() or None
+ tid = form_training_type_id
+ else:
+ cat = header.get("training_category")
+ tid = header.get("training_type_id")
+ if tid is not None:
+ try:
+ tid = int(tid)
+ except (TypeError, ValueError):
+ tid = None
+ schema = resolve_activity_attribute_schema(cur, cat, tid)
metrics = fetch_activity_session_metrics(cur, activity_log_id)
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
return {
diff --git a/backend/routers/activity.py b/backend/routers/activity.py
index 4be8cc1..da3e3ef 100644
--- a/backend/routers/activity.py
+++ b/backend/routers/activity.py
@@ -371,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)):
return get_mappable_activity_field_catalog(cur, pid)
+@router.get("/attribute-schema")
+def get_activity_attribute_schema(
+ training_category: Optional[str] = Query(None),
+ training_type_id: Optional[int] = Query(None),
+ session: dict = Depends(require_auth),
+):
+ """
+ Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session —
+ gleiche Logik wie resolve_activity_attribute_schema.
+ """
+ from data_layer.activity_session_metrics import resolve_activity_attribute_schema
+
+ cat = (training_category or "").strip() or None
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
+ return {"schema": schema}
+
+
@router.put("/{eid}")
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Update existing activity entry."""
@@ -422,6 +441,12 @@ def replace_activity_metrics(
@router.get("/{eid}")
def get_activity_session(
eid: str,
+ use_form_schema: bool = Query(
+ False,
+ description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
+ ),
+ training_category: Optional[str] = Query(None),
+ training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
@@ -435,7 +460,14 @@ def get_activity_session(
try:
with get_db() as conn:
cur = get_cursor(conn)
- unit = get_activity_session_logical_unit(cur, pid, eid)
+ unit = get_activity_session_logical_unit(
+ cur,
+ pid,
+ eid,
+ use_form_training_context=use_form_schema,
+ form_training_category=training_category,
+ form_training_type_id=training_type_id,
+ )
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
unit["header"] = serialize_dates(unit["header"])
diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx
index 163857c..45a872c 100644
--- a/frontend/src/pages/ActivityPage.jsx
+++ b/frontend/src/pages/ActivityPage.jsx
@@ -554,6 +554,12 @@ export default function ActivityPage() {
const [categories, setCategories] = useState({}) // v9d: Training categories
const [sessionDetail, setSessionDetail] = useState(null)
const [metricDraft, setMetricDraft] = useState({})
+ /** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */
+ const editSchemaKeysPrevRef = useRef(new Set())
+ const prevEditingIdRef = useRef(null)
+ const [manualSchema, setManualSchema] = useState(null)
+ const [manualMetricDraft, setManualMetricDraft] = useState({})
+ const manualSchemaKeysPrevRef = useRef(new Set())
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false)
@@ -639,18 +645,31 @@ export default function ActivityPage() {
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [fetchMonthsChain])
+ useEffect(() => {
+ editSchemaKeysPrevRef.current = new Set()
+ }, [editing?.id])
+
useEffect(() => {
if (!editing?.id) {
setSessionDetail(null)
setMetricDraft({})
setSessionLoadError(null)
+ prevEditingIdRef.current = null
return
}
let cancelled = false
setSessionLoadError(null)
+ if (prevEditingIdRef.current !== editing.id) {
+ setSessionDetail(null)
+ prevEditingIdRef.current = editing.id
+ }
;(async () => {
try {
- const d = await api.getActivitySession(editing.id)
+ const d = await api.getActivitySession(editing.id, {
+ useFormSchema: true,
+ training_category: editing.training_category,
+ training_type_id: editing.training_type_id,
+ })
if (!cancelled) setSessionDetail(d)
} catch (err) {
if (!cancelled) {
@@ -660,25 +679,89 @@ export default function ActivityPage() {
}
})()
return () => { cancelled = true }
- }, [editing?.id])
+ }, [editing?.id, editing?.training_category, editing?.training_type_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 : ''
+ const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key))
+ const oldKeys = editSchemaKeysPrevRef.current
+
+ setMetricDraft((prev) => {
+ const next = { ...prev }
+ for (const row of sessionDetail.metrics || []) {
+ const k = row.key
+ if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
+ continue
+ }
+ next[k] = row.value
}
- }
- setMetricDraft(m)
+ for (const s of sessionDetail.schema || []) {
+ if (!(s.key in next)) {
+ next[s.key] = s.data_type === 'boolean' ? false : ''
+ }
+ }
+ return next
+ })
+ editSchemaKeysPrevRef.current = newKeys
}, [sessionDetail])
+ useEffect(() => {
+ if (tab !== 'add') {
+ setManualSchema(null)
+ setManualMetricDraft({})
+ manualSchemaKeysPrevRef.current = new Set()
+ return
+ }
+ const tid = form.training_type_id
+ const cat = form.training_category
+ if (tid == null && (cat == null || cat === '')) {
+ setManualSchema(null)
+ setManualMetricDraft({})
+ manualSchemaKeysPrevRef.current = new Set()
+ return
+ }
+ let cancelled = false
+ ;(async () => {
+ try {
+ const r = await api.getActivityAttributeSchema({
+ training_category: cat || undefined,
+ training_type_id: tid ?? undefined,
+ })
+ if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : [])
+ } catch (err) {
+ console.error('attribute-schema:', err)
+ if (!cancelled) setManualSchema([])
+ }
+ })()
+ return () => { cancelled = true }
+ }, [tab, form.training_category, form.training_type_id])
+
+ useEffect(() => {
+ if (tab !== 'add' || !manualSchema) {
+ return
+ }
+ const newKeys = new Set(manualSchema.map((s) => s.key))
+ const oldKeys = manualSchemaKeysPrevRef.current
+
+ setManualMetricDraft((prev) => {
+ const next = { ...prev }
+ for (const s of manualSchema) {
+ const k = s.key
+ if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
+ continue
+ }
+ if (!(k in next)) {
+ next[k] = s.data_type === 'boolean' ? false : ''
+ }
+ }
+ return next
+ })
+ manualSchemaKeysPrevRef.current = newKeys
+ }, [tab, manualSchema])
+
const handleSave = async () => {
setSaving(true)
setError(null)
@@ -698,7 +781,20 @@ export default function ActivityPage() {
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)
+ const created = await api.createActivity(payload)
+ if (manualSchema && manualSchema.length > 0 && created?.id) {
+ try {
+ const metrics = buildMetricsPayload(manualSchema, manualMetricDraft)
+ await api.putActivityMetrics(created.id, { metrics })
+ } catch (metErr) {
+ console.error(metErr)
+ setError(
+ metErr.message ||
+ 'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.',
+ )
+ setTimeout(() => setError(null), 8000)
+ }
+ }
setSaved(true)
await load()
await loadUsage() // Reload usage after save
@@ -864,9 +960,23 @@ export default function ActivityPage() {
Training eintragen
{activityUsage && }
-
+
+ }
+ />
)}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 68d0f89..c52e032 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -352,7 +352,39 @@ export const api = {
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
- getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
+ /**
+ * @param {string} id
+ * @param {{ useFormSchema?: boolean, training_category?: string | null, training_type_id?: number | null }} [opts]
+ */
+ getActivitySession: (id, opts = {}) => {
+ const q = new URLSearchParams()
+ if (opts.useFormSchema) {
+ q.set('use_form_schema', 'true')
+ if (opts.training_category != null && opts.training_category !== '') {
+ q.set('training_category', String(opts.training_category))
+ }
+ if (opts.training_type_id != null && opts.training_type_id !== '') {
+ q.set('training_type_id', String(opts.training_type_id))
+ }
+ }
+ const qs = q.toString()
+ return req(`/activity/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`)
+ },
+ /**
+ * Attributprofil ohne Session (manuelle Erfassung / Vorschau).
+ * @param {{ training_category?: string | null, training_type_id?: number | null }} [params]
+ */
+ getActivityAttributeSchema: (params = {}) => {
+ const q = new URLSearchParams()
+ if (params.training_category != null && params.training_category !== '') {
+ q.set('training_category', String(params.training_category))
+ }
+ if (params.training_type_id != null && params.training_type_id !== '') {
+ q.set('training_type_id', String(params.training_type_id))
+ }
+ const qs = q.toString()
+ return req(`/activity/attribute-schema${qs ? `?${qs}` : ''}`)
+ },
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),