diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index a9fde12..af434da 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -505,6 +505,9 @@ def replace_activity_session_metrics( ) -> List[Dict[str, Any]]: """ Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...] + + Parameter mit gesetztem ``source_field`` werden nicht in EAV persistiert (kanonisch ``activity_log``), + konsistent zu ``upsert_session_metrics_from_csv_mapped`` — verhindert doppelte Speicherung nach PUT. """ cur.execute( """ @@ -535,6 +538,9 @@ def replace_activity_session_metrics( if not s["required"]: continue itk = s["key"] + sf_req = s.get("source_field") + if sf_req is not None and str(sf_req).strip(): + continue hit = payload_by_key.get(itk) if hit is None or hit.get("value") is None: raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}") @@ -547,6 +553,9 @@ def replace_activity_session_metrics( for item in metrics: k = str(item["parameter_key"]).strip() spec = by_key[k] + sf_raw = spec.get("source_field") + if sf_raw is not None and str(sf_raw).strip(): + continue val = item.get("value") if val is None: if spec["required"]: diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index 8f7b652..69622e5 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -10,6 +10,7 @@ from data_layer.activity_session_metrics import ( enrich_sessions_with_metrics, merge_column_backed_and_eav_metrics, merge_parameter_schema_rows, + replace_activity_session_metrics, resolve_activity_attribute_schema, upsert_session_metrics_from_csv_mapped, _row_value_tuple, @@ -377,3 +378,58 @@ def test_upsert_csv_writes_eav_when_no_source_field(mock_schema): 1, ) assert cur.asm_inserts == 1 + + +@patch("data_layer.activity_session_metrics.fetch_activity_session_metrics", return_value=[]) +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") +def test_replace_metrics_skips_inserts_for_source_field_column(mock_schema, mock_fetch): + """PUT /metrics: keine EAV-Zeile für Parameter mit source_field (kanonisch activity_log).""" + mock_schema.return_value = [ + { + "key": "avg_hr", + "training_parameter_id": 1, + "data_type": "integer", + "validation_rules": {}, + "source_field": "hr_avg", + "required": False, + }, + { + "key": "custom_x", + "training_parameter_id": 2, + "data_type": "string", + "validation_rules": {}, + "source_field": None, + "required": False, + }, + ] + + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + self._sql = sql + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + if "FROM activity_log" in getattr(self, "_sql", ""): + return { + "id": "00000000-0000-0000-0000-000000000002", + "profile_id": "00000000-0000-0000-0000-000000000001", + "training_category": "cardio", + "training_type_id": 1, + } + return None + + cur = Cur() + replace_activity_session_metrics( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + [ + {"parameter_key": "avg_hr", "value": 120}, + {"parameter_key": "custom_x", "value": "y"}, + ], + ) + assert cur.asm_inserts == 1 diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 50ea792..6ccb5f2 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -96,6 +96,28 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'training_subcategory', ]) +/** activity_log-Spalten, die bereits im EntryForm-Kopf bearbeitet werden — kein zweites Feld unter „Weitere Kennwerte“. */ +const ENTRY_FORM_SOURCE_COLUMNS = new Set([ + 'date', + 'start_time', + 'end_time', + 'duration_min', + 'kcal_active', + 'kcal_resting', + 'hr_avg', + 'hr_max', + 'rpe', + 'notes', +]) + +function schemaRowsForProfileExtras(schemaList) { + return (Array.isArray(schemaList) ? schemaList : []).filter((s) => { + const sf = s && s.source_field + if (!sf || !String(sf).trim()) return true + return !ENTRY_FORM_SOURCE_COLUMNS.has(String(sf).trim()) + }) +} + function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -113,6 +135,10 @@ function empty() { function buildMetricsPayload(schema, draft) { const out = [] for (const s of schema) { + const sf = s.source_field + if (sf && String(sf).trim() && ENTRY_FORM_SOURCE_COLUMNS.has(String(sf).trim())) { + continue + } const raw = draft[s.key] if (s.data_type === 'boolean') { if (raw === '' || raw === null || raw === undefined) { @@ -145,10 +171,21 @@ function buildMetricsPayload(schema, draft) { } function SessionMetricsFields({ schema, values, setValues, metrics }) { - const schemaList = Array.isArray(schema) ? schema : [] + const fullSchema = Array.isArray(schema) ? schema : [] + const schemaList = schemaRowsForProfileExtras(fullSchema) 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)) + const hiddenHeadlineParamKeys = new Set( + fullSchema + .filter((s) => s.source_field && ENTRY_FORM_SOURCE_COLUMNS.has(String(s.source_field).trim())) + .map((s) => s.key) + ) + const orphanMetrics = metricRows.filter((row) => { + if (!row || !row.key) return false + if (schemaKeys.has(row.key)) return false + if (hiddenHeadlineParamKeys.has(row.key)) return false + return true + }) if (schemaList.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))