From 2a6c437a0855339cb13aa0f07988ee0cb2728d14 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 16 Apr 2026 13:15:41 +0200 Subject: [PATCH] feat: Introduce activity schema headline binding for improved metrics handling - Added a new function `activitySchemaHeadlineBinding` to streamline the binding of profile parameters to their corresponding headline columns, enhancing clarity in the metrics display logic. - Refactored the `SessionMetricsFields` component to utilize the new binding function, simplifying the filtering of schema entries and improving maintainability. - Updated the logic in `ActivityPage` to leverage the binding function for determining the appropriate column for metrics, ensuring consistent data handling across the application. --- backend/scripts/inspect_activity_eav.py | 179 ++++++++++++++++++++++++ frontend/src/pages/ActivityPage.jsx | 38 +++-- 2 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 backend/scripts/inspect_activity_eav.py diff --git a/backend/scripts/inspect_activity_eav.py b/backend/scripts/inspect_activity_eav.py new file mode 100644 index 0000000..529b242 --- /dev/null +++ b/backend/scripts/inspect_activity_eav.py @@ -0,0 +1,179 @@ +""" +Diagnose: Was liegt in activity_session_metrics (EAV) vs. activity_log? + +Ausführung (mit gesetzten DB_*-Variablen wie die App, z. B. aus .env): + + cd backend + python scripts/inspect_activity_eav.py + +Lokal ohne Docker-Hostname: z. B. ``set DB_HOST=127.0.0.1`` (Windows) / ``export DB_HOST=127.0.0.1``, +Port/User/Pass wie in der laufenden Postgres-Instanz. + +Im Backend-Container (Compose-Service meist ``backend``, Arbeitsverzeichnis ``/app``): + + docker compose exec backend python /app/scripts/inspect_activity_eav.py + +Optional: + python scripts/inspect_activity_eav.py --limit 30 + python scripts/inspect_activity_eav.py --profile + python scripts/inspect_activity_eav.py --activity + +Keine Schreibzugriffe — nur SELECT. +""" +from __future__ import annotations + +import argparse +import os +import sys + +# backend/ als Import-Root +_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _BACKEND_ROOT not in sys.path: + sys.path.insert(0, _BACKEND_ROOT) + + +def _val_row(r: dict) -> str | None: + dt = r.get("data_type") + if dt == "integer": + v = r.get("value_int") + return str(v) if v is not None else None + if dt == "float": + v = r.get("value_num") + return str(v) if v is not None else None + if dt == "string": + v = r.get("value_text") + return repr(v) if v is not None else None + if dt == "boolean": + v = r.get("value_bool") + return str(v) if v is not None else None + return None + + +def main() -> None: + parser = argparse.ArgumentParser(description="EAV activity_session_metrics inspizieren") + parser.add_argument("--limit", type=int, default=40, help="Zeilen Report A/B") + parser.add_argument("--profile", type=str, default=None, help="profile_id filtern") + parser.add_argument("--activity", type=str, default=None, help="activity_log.id (einzelne Session)") + args = parser.parse_args() + + from db import get_db, get_cursor + + if args.activity: + with get_db() as conn: + with get_cursor(conn) as cur: + cur.execute( + """ + SELECT al.id, al.profile_id, al.date, al.start_time, al.source, + al.training_category, al.training_type_id, al.activity_type, + al.duration_min, al.kcal_active, al.hr_avg, al.hr_max, al.distance_km + FROM activity_log al + WHERE al.id = %s::uuid + """, + (args.activity,), + ) + h = cur.fetchone() + if not h: + print("activity_log: keine Zeile für diese id") + return + print("=== activity_log (Kopfzeile) ===") + for k, v in dict(h).items(): + print(f" {k}: {v}") + + cur.execute( + """ + SELECT m.id AS metric_id, tp.key, tp.data_type, tp.source_field, + m.value_num, m.value_int, m.value_text, m.value_bool, m.updated_at + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id = %s::uuid + ORDER BY tp.key + """, + (args.activity,), + ) + rows = cur.fetchall() + print(f"\n=== activity_session_metrics ({len(rows)} Zeilen) ===") + for r in rows: + d = dict(r) + print( + f" {d['key']} ({d['data_type']}) " + f"value={_val_row(d)!r} source_field={d.get('source_field')!r} " + f"updated_at={d.get('updated_at')}" + ) + if not rows: + print(" (keine EAV-Zeilen)") + return + + prof_filter = "" + if args.profile: + prof_filter = " AND al.profile_id = %s::uuid " + params_a: tuple = (args.profile, args.limit) if args.profile else (args.limit,) + params_b: tuple = (args.profile, args.limit) if args.profile else (args.limit,) + + q_recent_eav = f""" + SELECT + al.id AS activity_id, + al.profile_id, + al.date, + al.start_time, + al.source, + al.training_type_id, + al.training_category, + tp.key AS parameter_key, + tp.data_type, + tp.source_field AS tp_source_field, + m.value_num, + m.value_int, + m.value_text, + m.value_bool, + m.updated_at + FROM activity_session_metrics m + JOIN activity_log al ON al.id = m.activity_log_id + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE 1=1 {prof_filter} + ORDER BY m.updated_at DESC NULLS LAST, al.date DESC, al.start_time DESC + LIMIT %s + """ + + q_csv_no_eav = f""" + SELECT + al.id AS activity_id, + al.profile_id, + al.date, + al.start_time, + al.source, + al.training_type_id, + al.training_category, + (SELECT COUNT(*) FROM activity_session_metrics m WHERE m.activity_log_id = al.id) AS eav_count + FROM activity_log al + WHERE al.source = 'csv' {prof_filter} + ORDER BY al.date DESC, al.start_time DESC + LIMIT %s + """ + + with get_db() as conn: + with get_cursor(conn) as cur: + print("=== A) Neueste EAV-Zeilen (join activity_log + training_parameters) ===\n") + cur.execute(q_recent_eav, params_a) + for r in cur.fetchall(): + d = dict(r) + v = _val_row(d) + print( + f"{d['date']} {d['start_time']} | {d['activity_id']} | src={d['source']!r} | " + f"type={d['training_type_id']} cat={d['training_category']!r} | " + f"{d['parameter_key']}={v!r} (tp.source_field={d.get('tp_source_field')!r})" + ) + + print("\n=== B) Neueste CSV-importierte Sessions: EAV-Anzahl pro Zeile ===\n") + cur.execute(q_csv_no_eav, params_b) + for r in cur.fetchall(): + d = dict(r) + print( + f"{d['date']} {d['start_time']} | {d['activity_id']} | " + f"type={d['training_type_id']} | eav_count={d['eav_count']}" + ) + + print("\nFertig. Für eine Session im Detail: --activity ") + + +if __name__ == "__main__": + main() diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 07f9c3e..ea8284e 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -106,6 +106,23 @@ const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([ 'notes', ]) +/** + * Bindung Profilparameter ↔ Kopfzeile: Entweder source_field zeigt auf eine Kopfspalte, + * oder der Parameter-key ist selbst eine Kopfspalte (häufig nach Migration / ohne source_field). + * @returns {{ headlineCol: string, parameterKey: string } | null} + */ +function activitySchemaHeadlineBinding(s) { + if (!s || !s.key) return null + const sf = s.source_field != null ? String(s.source_field).trim() : '' + if (sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) { + return { headlineCol: sf, parameterKey: s.key } + } + if (ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(s.key)) { + return { headlineCol: s.key, parameterKey: s.key } + } + return null +} + function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -157,18 +174,9 @@ 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), + schemaList.filter((s) => activitySchemaHeadlineBinding(s) != null).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 schemaForDisplay = schemaList.filter((s) => activitySchemaHeadlineBinding(s) == null) const metricRows = Array.isArray(metrics) ? metrics : [] const schemaKeys = new Set(schemaForDisplay.map((s) => s.key)) const orphanMetrics = metricRows.filter( @@ -655,10 +663,10 @@ export default function ActivityPage() { if (sessionDetail?.schema?.length > 0) { const draftForMetrics = { ...metricDraft } for (const s of sessionDetail.schema) { - const sf = s.source_field != null ? String(s.source_field).trim() : '' - if (!sf || !ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) continue - if (!(s.key in draftForMetrics)) continue - const rawCol = payload[sf] !== undefined ? payload[sf] : editing?.[sf] + const bind = activitySchemaHeadlineBinding(s) + if (!bind || !(s.key in draftForMetrics)) continue + const rawCol = + payload[bind.headlineCol] !== undefined ? payload[bind.headlineCol] : editing?.[bind.headlineCol] if (rawCol === undefined) continue if (s.data_type === 'boolean') { draftForMetrics[s.key] = !!rawCol