diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index a9fde12..84fe49c 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -252,14 +252,15 @@ def upsert_session_metrics_from_csv_mapped( training_type_id: Optional[int], ) -> None: """ - EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen). + EAV für Trainingsparameter aus CSV. - Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log; - hier keine doppelten EAV-Zeilen für dieselben Registry-Keys. + Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie + + Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das + UI-/Auswertungs-Schema — fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf. + nur in ``activity_log``-Kernfeldern). - Ist ``training_parameters.source_field`` gesetzt, ist die zugehörige ``activity_log``-Spalte - kanonisch (wie beim Lesen in ``merge_column_backed_and_eav_metrics``) — kein EAV-Schreiben, - auch wenn der Parameter-Key vom Registry-Schlüssel abweicht. + Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys. + Bei gesetztem ``training_parameters.source_field`` ist die Spalte kanonisch — kein EAV-Schreiben. """ cur.execute( "SELECT profile_id FROM activity_log WHERE id = %s", @@ -315,9 +316,11 @@ def merge_column_backed_and_eav_metrics( eav_metrics: Sequence[Dict[str, Any]], ) -> List[Dict[str, Any]]: """ - Effektive Metrikliste: Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn - die Spalte befüllt und koerzierbar ist; sonst EAV, sonst Legacy-Spalte (EAV-primär). Eine Semantik - erscheint nur einmal — konsistent mit CSV-EAV-Upsert (dort kein Schreiben bei gesetztem ``source_field``). + Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp). + + Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn die Spalte befüllt und + koerzierbar ist; sonst EAV, sonst Legacy-Spalte. EAV-Zeilen zu Parametern, die nicht im Schema sind, + werden nicht ausgegeben (Darstellung und Auswertung folgen ausschließlich dem konfigurierten Profil). """ eav_by_key = {m["key"]: m for m in eav_metrics} merged: List[Dict[str, Any]] = [] @@ -374,11 +377,6 @@ def merge_column_backed_and_eav_metrics( except (TypeError, ValueError): pass - for m in eav_metrics: - if m["key"] in keys_handled: - continue - merged.append(dict(m)) - merged.sort(key=lambda x: x["key"]) return merged diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index 8f7b652..8cb1bdf 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -219,8 +219,7 @@ def test_enrich_sessions_batch(mock_resolve): sessions = [{"id": aid}, {"id": bid}] enrich_sessions_with_metrics(_Cur(), sessions) - assert sessions[0]["session_metrics"][0]["value"] == 7 - assert sessions[0]["session_metrics"][0]["key"] == "rpe" + assert sessions[0]["session_metrics"] == [] assert sessions[1]["session_metrics"] == [] @@ -274,7 +273,8 @@ def test_merge_falls_back_to_eav_when_column_empty(): assert out[0]["value"] == 99.0 -def test_merge_keeps_eav_only_keys(): +def test_merge_ignores_eav_when_parameter_not_in_schema(): + """Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste.""" schema = [] eav = [ { @@ -286,8 +286,7 @@ def test_merge_keeps_eav_only_keys(): } ] out = merge_column_backed_and_eav_metrics({}, schema, eav) - assert len(out) == 1 - assert out[0]["key"] == "custom_param" + assert out == [] def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): @@ -377,3 +376,29 @@ def test_upsert_csv_writes_eav_when_no_source_field(mock_schema): 1, ) assert cur.asm_inserts == 1 + + +@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[]) +def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve): + """Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen.""" + class Cur: + def __init__(self): + self.asm_inserts = 0 + + def execute(self, sql, params=None): + if "INSERT INTO activity_session_metrics" in sql: + self.asm_inserts += 1 + + def fetchone(self): + return {"profile_id": "00000000-0000-0000-0000-000000000001"} + + cur = Cur() + upsert_session_metrics_from_csv_mapped( + cur, + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000002", + {"stola": 12}, + "cardio", + 1, + ) + assert cur.asm_inserts == 0 diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 50ea792..07f9c3e 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -96,6 +96,16 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ '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', +]) + function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -146,16 +156,35 @@ function buildMetricsPayload(schema, draft) { function SessionMetricsFields({ schema, values, setValues, metrics }) { const schemaList = Array.isArray(schema) ? schema : [] + const headlineDuplicateKeys = new Set( + schemaList + .filter((s) => { + const sf = s.source_field != null ? String(s.source_field).trim() : '' + return sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf) + }) + .map((s) => s.key), + ) + const schemaForDisplay = schemaList.filter((s) => { + const sf = s.source_field != null ? String(s.source_field).trim() : '' + if (!sf) return true + return !ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf) + }) 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 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 (schemaList.length === 0 && orphanMetrics.length === 0) return null + if (schemaForDisplay.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) return (
Weitere Kennwerte (Profil)
- {schemaList.map((s) => ( + {schemaForDisplay.map((s) => (