From 7d6fdab812ce2b130d6b5de45da0c458d4c5e002 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 16 Apr 2026 11:04:43 +0200 Subject: [PATCH] feat: Enhance activity import functionality with additional metrics - Updated the `_import_activity` function to include new metrics: duration_min, kcal_active, kcal_resting, hr_avg, hr_max, and distance_km during CSV imports. - Modified the `insert_activity_csv_minimal` function to accept and store these additional metrics in the activity log. - Enhanced the `run_activity_post_write_hooks_import` function to utilize the new metrics for auto-evaluation after activity imports. - Updated the activity import router to pass the new metrics from the CSV file to the database functions, ensuring comprehensive data handling. - Improved frontend handling of activity entry forms to accommodate the new metrics, enhancing user experience during activity log edits. --- backend/csv_parser/executor.py | 13 ++++ .../activity_persistence_orchestrator.py | 76 +++++++++++-------- backend/routers/activity.py | 34 +++++---- frontend/src/pages/ActivityPage.jsx | 34 ++++++++- 4 files changed, 109 insertions(+), 48 deletions(-) diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 03a30ed..69f1c06 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -899,6 +899,12 @@ def _import_activity( start_time=workout_start_t, end_time=end_str or None, activity_type=wtype, + duration_min=registry_updates.get("duration_min"), + kcal_active=registry_updates.get("kcal_active"), + kcal_resting=registry_updates.get("kcal_resting"), + hr_avg=registry_updates.get("hr_avg"), + hr_max=registry_updates.get("hr_max"), + distance_km=registry_updates.get("distance_km"), training_type_id=training_type_id, training_category=training_category, training_subcategory=training_subcategory, @@ -915,7 +921,14 @@ def _import_activity( cur, profile_id, str(aid), + workout_date=iso, training_type_id=training_type_id, + duration_min=registry_updates.get("duration_min"), + hr_avg=registry_updates.get("hr_avg"), + hr_max=registry_updates.get("hr_max"), + distance_km=registry_updates.get("distance_km"), + kcal_active=registry_updates.get("kcal_active"), + kcal_resting=registry_updates.get("kcal_resting"), ) upsert_session_metrics_from_csv_mapped( cur, diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 4971d58..4629343 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -216,17 +216,18 @@ def insert_activity_csv_minimal( start_time: Any, end_time: Any, activity_type: str, + duration_min: Any, + kcal_active: Any, + kcal_resting: Any, + hr_avg: Any, + hr_max: Any, + distance_km: Any, training_type_id: Any, training_category: Any, training_subcategory: Any, source: str, ) -> None: - """ - INSERT Kopfzeile für Universal-CSV / Legacy-Import. - - Metriken aus ``activity_csv_registry_updates_from_mapped`` (oder manuelles Dict) — - ausschließlich via ``update_activity_columns``; keine fest verdrahteten hr_avg-Parameter. - """ + """INSERT activity_log-Zeile (Universal-CSV): Kernspalten im INSERT; optional zusätzliches PATCH.""" cur.execute( """ INSERT INTO activity_log ( @@ -234,7 +235,7 @@ def insert_activity_csv_minimal( kcal_active, kcal_resting, hr_avg, hr_max, distance_km, source, training_type_id, training_category, training_subcategory, created ) - VALUES (%s,%s,%s,%s,%s,%s,NULL,NULL,NULL,NULL,NULL,NULL,%s,%s,%s,%s,CURRENT_TIMESTAMP) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP) """, ( eid, @@ -243,6 +244,12 @@ def insert_activity_csv_minimal( start_time, end_time, activity_type, + duration_min, + kcal_active, + kcal_resting, + hr_avg, + hr_max, + distance_km, source, training_type_id, training_category, @@ -280,32 +287,37 @@ def run_activity_post_write_hooks_import( profile_id: str, eid: str, *, - training_type_id: Optional[int] = None, + workout_date: str, + training_type_id: Optional[int], + duration_min: Any, + hr_avg: Any, + hr_max: Any, + distance_km: Any, + kcal_active: Any, + kcal_resting: Any, ) -> None: - """Auto-Eval nach Import — liest die Session aus der DB (gleiche Felder wie REST-Hook).""" - if not _EVALUATION_AVAILABLE or not _evaluate_and_save_activity: - return - cur.execute( - """ - SELECT id, profile_id, date, training_type_id, duration_min, - hr_avg, hr_max, distance_km, kcal_active, kcal_resting, - rpe, pace_min_per_km, cadence, elevation_gain - FROM activity_log - WHERE id = %s AND profile_id = %s - """, - (eid, profile_id), - ) - row = cur.fetchone() - if not row: - return - activity_dict = dict(row) - tid = training_type_id if training_type_id is not None else activity_dict.get("training_type_id") - if not tid: - return - try: - _evaluate_and_save_activity(cur, eid, activity_dict, int(tid), profile_id) - except Exception as eval_err: - logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err) + """Auto-Eval nach Import (gleiche Transaktion wie Schreibpfad — keine Abhängigkeit vom DB-Read-Timing).""" + if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity: + try: + activity_dict = { + "id": eid, + "profile_id": profile_id, + "date": workout_date, + "training_type_id": training_type_id, + "duration_min": duration_min, + "hr_avg": hr_avg, + "hr_max": hr_max, + "distance_km": distance_km, + "kcal_active": kcal_active, + "kcal_resting": kcal_resting, + "rpe": None, + "pace_min_per_km": None, + "cadence": None, + "elevation_gain": None, + } + _evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id) + except Exception as eval_err: + logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err) def merge_activity_csv_module_fields( diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 168d685..852fc8e 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -641,7 +641,14 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional cur, pid, str(existing_id), + workout_date=workout_date, training_type_id=training_type_id, + duration_min=duration_min, + hr_avg=hr_av, + hr_max=hr_mx, + distance_km=dist_km, + kcal_active=kcal_a, + kcal_resting=kcal_r, ) else: new_id = new_activity_id() @@ -653,31 +660,30 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional start_time=workout_start_t, end_time=row.get("End", "") or None, activity_type=wtype, + duration_min=duration_min, + kcal_active=kcal_a, + kcal_resting=kcal_r, + hr_avg=hr_av, + hr_max=hr_mx, + distance_km=dist_km, training_type_id=training_type_id, training_category=training_category, training_subcategory=training_subcategory, source="apple_health", ) - apple_metrics = { - k: v - for k, v in { - "duration_min": duration_min, - "kcal_active": kcal_a, - "kcal_resting": kcal_r, - "hr_avg": hr_av, - "hr_max": hr_mx, - "distance_km": dist_km, - }.items() - if v is not None - } - if apple_metrics: - update_activity_columns(cur, pid, new_id, apple_metrics) inserted += 1 run_activity_post_write_hooks_import( cur, pid, new_id, + workout_date=workout_date, training_type_id=training_type_id, + duration_min=duration_min, + hr_avg=hr_av, + hr_max=hr_mx, + distance_km=dist_km, + kcal_active=kcal_a, + kcal_resting=kcal_r, ) except Exception as e: logger.warning(f"Import row failed: {e}") diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 195475b..1ca8551 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -96,7 +96,7 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'training_subcategory', ]) -/** activity_log-Spalten, die im EntryForm editiert werden (nicht aus metricDraft überschreiben). */ +/** activity_log-Spalten, die im EntryForm editiert werden (Kurzform). */ const ACTIVITY_ENTRY_FORM_COLUMNS = new Set([ 'duration_min', 'kcal_active', @@ -568,6 +568,27 @@ export default function ActivityPage() { setMetricDraft(m) }, [sessionDetail]) + /** Nach GET /activity/:id: Kopf-Spalten ins EntryForm, wenn die Listenzeile leer war (Parameter-Keys vs. Spalten). */ + useEffect(() => { + const h = sessionDetail?.header + if (!h?.id || !editing?.id) return + if (String(h.id) !== String(editing.id)) return + setEditing((prev) => { + if (String(h.id) !== String(prev.id)) return prev + let changed = false + const next = { ...prev } + for (const col of ACTIVITY_ENTRY_FORM_COLUMNS) { + const cur = next[col] + const empty = cur === null || cur === undefined || cur === '' + if (empty && h[col] != null && h[col] !== '') { + next[col] = h[col] + changed = true + } + } + return changed ? next : prev + }) + }, [sessionDetail, editing?.id]) + const handleSave = async () => { setSaving(true) setError(null) @@ -612,7 +633,16 @@ export default function ActivityPage() { if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue const useFormColumn = ACTIVITY_ENTRY_FORM_COLUMNS.has(col) if (!useFormColumn && !(s.key in metricDraft)) continue - const raw = useFormColumn ? editing[col] : metricDraft[s.key] + let raw + if (useFormColumn) { + const fromForm = editing[col] + const fromDraft = metricDraft[s.key] + const formEmpty = fromForm === null || fromForm === undefined || fromForm === '' + const draftEmpty = fromDraft === null || fromDraft === undefined || fromDraft === '' + raw = !formEmpty ? fromForm : !draftEmpty ? fromDraft : fromForm + } else { + raw = metricDraft[s.key] + } const rawStr = raw === null || raw === undefined ? '' : String(raw).trim() if (rawStr === '') { payload[col] = null