From 3296dfca28a2b2f7e626ed8b0fd1eb648968398c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 12:53:35 +0200 Subject: [PATCH] feat: Enhance activity log handling and session metrics synchronization - Added a new function to synchronize session metrics with activity log entries, ensuring data consistency. - Updated the create and update activity endpoints to call the synchronization function after inserting or modifying activity logs. - Introduced a set of allowed keys for activity log payloads to streamline data handling in the frontend. - Improved data coercion logic for various data types in the frontend to ensure accurate data submission. --- .../data_layer/activity_session_metrics.py | 97 +++++++++++++++++++ backend/routers/activity.py | 49 +++++++--- frontend/src/pages/ActivityPage.jsx | 40 ++++++++ frontend/src/utils/api.js | 2 +- 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 03aef2b..e00fc8b 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -5,9 +5,12 @@ See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md """ from __future__ import annotations +import logging from decimal import Decimal from typing import Any, Dict, List, Optional, Sequence +logger = logging.getLogger(__name__) + class ActivitySessionMetricsError(Exception): """Raised by Layer 1; routers map to HTTP (404/400).""" @@ -193,6 +196,97 @@ def _row_value_tuple(data_type: str, value: Any) -> tuple: raise ValueError(data_type) +def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any: + """Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet.""" + if data_type == "integer": + if isinstance(raw, bool): + raise TypeError("boolean nicht als integer erlaubt") + return int(round(float(raw))) + if data_type == "float": + return float(raw) + if data_type == "string": + return str(raw) if raw is not None else "" + if data_type == "boolean": + if isinstance(raw, bool): + return raw + s = str(raw).strip().lower() + if s in ("true", "1", "t", "yes"): + return True + if s in ("false", "0", "f", "no", ""): + return False + raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}") + raise ValueError(data_type) + + +def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None: + """ + EAV-Zeilen für alle Schema-Parameter mit gesetztem source_field aus der activity_log-Zeile + schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit. + + Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben + Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen. + """ + 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): + return + header = dict(row) + schema = resolve_activity_attribute_schema( + cur, header.get("training_category"), header.get("training_type_id") + ) + for spec in schema: + sf = spec.get("source_field") + if sf is None or (isinstance(sf, str) and not str(sf).strip()): + continue + col = str(sf).strip() + if col not in header: + continue + raw = header[col] + tid = spec["training_parameter_id"] + dt = spec["data_type"] + rules = _validation_rules_dict(spec["validation_rules"]) + + if raw is None: + cur.execute( + """ + DELETE FROM activity_session_metrics + WHERE activity_log_id = %s AND training_parameter_id = %s + """, + (activity_log_id, tid), + ) + continue + + try: + coerced = _coerce_raw_value_for_parameter(dt, raw) + _validate_single_value(dt, coerced, rules) + except (ActivitySessionMetricsError, TypeError, ValueError) as ex: + logger.warning( + "sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s", + spec.get("key"), + col, + ex, + ) + continue + + vn, vi, vt, vb = _row_value_tuple(dt, coerced) + cur.execute( + """ + INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at + ) VALUES (%s, %s, %s, %s, %s, %s, NOW()) + ON CONFLICT (activity_log_id, training_parameter_id) + DO UPDATE SET + value_num = EXCLUDED.value_num, + value_int = EXCLUDED.value_int, + value_text = EXCLUDED.value_text, + value_bool = EXCLUDED.value_bool, + updated_at = NOW() + """, + (activity_log_id, tid, vn, vi, vt, vb), + ) + + def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]: cur.execute( """ @@ -297,6 +391,9 @@ def replace_activity_session_metrics( (activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb), ) + # Übergang: Spalten in activity_log sind maßgeblich — EAV für alle source_field-Parameter angleichen + sync_column_backed_session_metrics(cur, profile_id, activity_log_id) + return fetch_activity_session_metrics(cur, activity_log_id) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 348fef4..e8f7609 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -17,6 +17,10 @@ from models import ActivityEntry, ActivityMetricsReplace from routers.profiles import get_pid from feature_logger import log_feature_usage from quality_filter import get_quality_filter_sql +from data_layer.activity_session_metrics import sync_column_backed_session_metrics + +router = APIRouter(prefix="/api/activity", tags=["activity"]) +logger = logging.getLogger(__name__) # Evaluation import with error handling (Phase 1.2) try: @@ -27,9 +31,6 @@ except Exception as e: EVALUATION_AVAILABLE = False evaluate_and_save_activity = None -router = APIRouter(prefix="/api/activity", tags=["activity"]) -logger = logging.getLogger(__name__) - @router.get("") def list_activity( @@ -98,13 +99,33 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default d = e.model_dump() with get_db() as conn: cur = get_cursor(conn) - cur.execute("""INSERT INTO activity_log + cur.execute( + """INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting, - hr_avg,hr_max,distance_km,rpe,source,notes,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", - (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'], - d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], - d['rpe'],d['source'],d['notes'])) + hr_avg,hr_max,distance_km,rpe,source,notes, + training_type_id,training_category,training_subcategory,created) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", + ( + eid, + pid, + d["date"], + d["start_time"], + d["end_time"], + d["activity_type"], + d["duration_min"], + d["kcal_active"], + d["kcal_resting"], + d["hr_avg"], + d["hr_max"], + d["distance_km"], + d["rpe"], + d["source"], + d["notes"], + d.get("training_type_id"), + d.get("training_category"), + d.get("training_subcategory"), + ), + ) # Phase 1.2: Auto-evaluation after INSERT if EVALUATION_AVAILABLE: @@ -127,6 +148,8 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default except Exception as eval_error: logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}") + sync_column_backed_session_metrics(cur, str(pid), eid) + # Phase 2: Increment usage counter (always for new entries) increment_feature_usage(pid, 'activity_entries') @@ -224,6 +247,8 @@ def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Head except Exception as eval_error: logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}") + sync_column_backed_session_metrics(cur, str(pid), eid) + return {"id":eid} @@ -241,7 +266,6 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), def replace_activity_metrics( eid: str, body: ActivityMetricsReplace, - x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """ @@ -252,7 +276,7 @@ def replace_activity_metrics( replace_activity_session_metrics, ) - pid = get_pid(x_profile_id) + pid = str(session["profile_id"]) payload = [m.model_dump() for m in body.metrics] try: with get_db() as conn: @@ -267,7 +291,6 @@ def replace_activity_metrics( @router.get("/{eid}") def get_activity_session( eid: str, - x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1).""" @@ -277,7 +300,7 @@ def get_activity_session( ) from data_layer.utils import serialize_dates - pid = get_pid(x_profile_id) + pid = str(session["profile_id"]) try: with get_db() as conn: cur = get_cursor(conn) diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index e68054b..2ebfb7d 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -15,6 +15,26 @@ const ACTIVITY_TYPES = [ 'Cardio Dance','Geist & Körper','Sonstiges' ] +/** Spalten, die mit ActivityEntry / UPDATE activity_log geschrieben werden dürfen (Übergang: Profilfelder → Kopfzeile). */ +const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ + 'date', + 'start_time', + 'end_time', + 'activity_type', + 'duration_min', + 'kcal_active', + 'kcal_resting', + 'hr_avg', + 'hr_max', + 'distance_km', + 'rpe', + 'source', + 'notes', + 'training_type_id', + 'training_category', + 'training_subcategory', +]) + function empty() { return { date: dayjs().format('YYYY-MM-DD'), @@ -360,6 +380,26 @@ export default function ActivityPage() { try { const payload = { ...editing } delete payload.id + for (const s of sessionDetail?.schema || []) { + const col = s.source_field + if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue + if (!(s.key in metricDraft)) continue + const raw = metricDraft[s.key] + if (raw === '' || raw === null || raw === undefined) continue + let v = raw + if (s.data_type === 'integer') { + v = parseInt(String(raw), 10) + if (Number.isNaN(v)) continue + } else if (s.data_type === 'float') { + v = parseFloat(String(raw)) + if (Number.isNaN(v)) continue + } else if (s.data_type === 'boolean') { + v = !!raw + } else { + v = String(raw) + } + payload[col] = v + } if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min) if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active) if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg) diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 89cfb02..9233599 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -340,7 +340,7 @@ export const api = { getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`), putActivityMetrics: (id, body) => - req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)), + req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)), // Sleep Module (v9d Phase 2b) listSleep: (l=90) => req(`/sleep?limit=${l}`),