From 48508c164e1b0c4e82940e0ab4e2c31601e23aac Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 11:49:14 +0200 Subject: [PATCH 01/17] feat: Add Activity Session Metrics functionality - Introduced Activity Session Metrics for enhanced tracking of session data. - Updated backend to support new API endpoints for managing session metrics. - Added new Pydantic models for activity metrics and replaced metrics functionality. - Enhanced data layer to include session metrics in recent training session data. - Updated documentation to reflect changes in session metrics handling. --- .claude/docs/README.md | 1 + ...CTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md | 122 ++++++ CLAUDE.md | 7 + backend/data_layer/activity_metrics.py | 4 + .../data_layer/activity_session_metrics.py | 364 ++++++++++++++++++ backend/main.py | 3 + .../054_activity_session_metrics_eav.sql | 80 ++++ backend/models.py | 16 +- .../activity_session_insights.py | 11 +- backend/routers/activity.py | 53 ++- .../admin_activity_attribute_profiles.py | 184 +++++++++ backend/routers/admin_training_parameters.py | 215 +++++++++++ .../tests/test_activity_session_metrics.py | 204 ++++++++++ 13 files changed, 1257 insertions(+), 7 deletions(-) create mode 100644 .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md create mode 100644 backend/data_layer/activity_session_metrics.py create mode 100644 backend/migrations/054_activity_session_metrics_eav.sql create mode 100644 backend/routers/admin_activity_attribute_profiles.py create mode 100644 backend/routers/admin_training_parameters.py create mode 100644 backend/tests/test_activity_session_metrics.py diff --git a/.claude/docs/README.md b/.claude/docs/README.md index eb9d192..c2834d7 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -113,6 +113,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp | `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 | | `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch | | `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste | +| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration | | `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) | --- diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md new file mode 100644 index 0000000..c0581bf --- /dev/null +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -0,0 +1,122 @@ +# Activity Session Metrics (EAV) – Umsetzungs- & Agent-Guide + +**Stand:** 2026-04-14 +**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen. +**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen. + +--- + +## 1. Produktions-Migrationen (Pflicht) + +- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story. +- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten). +- **Prod-Checkliste vor Deploy:** + 1. Backup / Snapshot der DB. + 2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren. + 3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed. +- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6). + +--- + +## 2. Datenmodell (Ist nach Migration 054) + +| Tabelle | Zweck | +|---------|--------| +| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) – bereits Migration 013; Admin-API ergänzt. | +| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. | +| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). | +| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. | +| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) – für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. | + +**Merge-Logik effektives Schema** (Layer 1, eine Funktion): + +1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`. +2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv). +3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen. +4. Sortierung: `sort_order` aufsteigend, dann `key`. + +--- + +## 3. Layer 1 – Kanonische Module + +| Modul | Pfad | Aufgabe | +|-------|------|---------| +| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`. | + +**Regeln für Agenten:** + +- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern. +- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`). +- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`. + +--- + +## 4. API (Ist / geplant) + +### Admin (`require_admin`) + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen | +| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) | +| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie | +| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen | +| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen | +| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ | +| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen | +| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen | + +Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`. + +### Nutzer (`require_auth`) + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) | +| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) | + +`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt. + +--- + +## 5. Agent-Checkliste (nächste Iterationen) + +- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter. +- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`. +- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor). +- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`. +- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue). + +--- + +## 6. Backfill (nicht in Migration 054) + +Separates Skript oder Migration **055+**, wenn fachlich freigegeben: + +- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert. +- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren). + +--- + +## 7. Automatische Tests (pytest, ohne DB) + +Aus **`backend/`**: + +```bash +python -m pytest tests/test_activity_session_metrics.py -v +``` + +Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor. + +--- + +## 8. Referenzen + +- Migration 013: `training_parameters` +- Migration 004/014: `training_types`, `activity_log`-Erweiterungen +- Pattern Admin-Katalog: `routers/admin_reference_value_types.py` +- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data` + +--- + +**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren. diff --git a/CLAUDE.md b/CLAUDE.md index 3e6b4c9..0de72bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ > | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** | > | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** | > | **Agent-Einstieg** | **`.claude/README.md`** | +> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** | ## Claude Code Verantwortlichkeiten @@ -115,6 +116,12 @@ frontend/src/ - **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75 - **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt. +### Updates (14.04.2026 - Activity Session Metrics EAV, Kern-Backend) + +- **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`). +- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable). +- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt). + ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) - **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance). diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index 4b57358..a1afd7e 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -22,6 +22,7 @@ from typing import Dict, List, Optional, Any from datetime import datetime, timedelta, date, time import statistics from db import get_db, get_cursor, r2d +from data_layer.activity_session_metrics import enrich_sessions_with_metrics from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates @@ -1112,6 +1113,7 @@ def get_training_sessions_recent_weeks_data( (profile_id, cutoff), ) rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, rows) if not rows: return { @@ -1141,6 +1143,7 @@ def get_training_sessions_recent_weeks_data( hr_m = r.get("hr_max") by_week[wk].append( { + "id": str(r["id"]), "date": d, "start_time": str(r["start_time"]) if r.get("start_time") is not None else None, "activity_type": r.get("activity_type"), @@ -1151,6 +1154,7 @@ def get_training_sessions_recent_weeks_data( "hr_avg": int(hr_a) if hr_a is not None else None, "hr_max": int(hr_m) if hr_m is not None else None, "rpe": int(r["rpe"]) if r.get("rpe") is not None else None, + "session_metrics": r.get("session_metrics", []), } ) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py new file mode 100644 index 0000000..03aef2b --- /dev/null +++ b/backend/data_layer/activity_session_metrics.py @@ -0,0 +1,364 @@ +""" +Activity session metrics (EAV) and resolved attribute schema — Layer 1. + +See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Dict, List, Optional, Sequence + + +class ActivitySessionMetricsError(Exception): + """Raised by Layer 1; routers map to HTTP (404/400).""" + + def __init__(self, status_code: int, detail: str): + self.status_code = status_code + self.detail = detail + super().__init__(detail) + + +def _effective_training_category( + cur, training_category: Optional[str], training_type_id: Optional[int] +) -> Optional[str]: + if training_category: + return training_category.strip() or None + if training_type_id is None: + return None + cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,)) + row = cur.fetchone() + if row and row.get("category"): + return row["category"] + return None + + +def merge_parameter_schema_rows( + category_rows: Sequence[Dict[str, Any]], + type_rows: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Pure merge: category assignments + type assignments → sorted schema list. + Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases). + """ + merged: Dict[int, Dict[str, Any]] = {} + + for r in category_rows: + pid = r["training_parameter_id"] + merged[pid] = { + "training_parameter_id": pid, + "key": r["key"], + "name_de": r["name_de"], + "name_en": r["name_en"], + "param_category": r["param_category"], + "data_type": r["data_type"], + "unit": r["unit"], + "validation_rules": r["validation_rules"] or {}, + "source_field": r["source_field"], + "sort_order": r["cat_sort"], + "required": bool(r["cat_required"]), + "ui_group": r["cat_ui_group"], + } + + for r in type_rows: + pid = r["training_parameter_id"] + base = merged.get(pid) + if base is None: + merged[pid] = { + "training_parameter_id": pid, + "key": r["key"], + "name_de": r["name_de"], + "name_en": r["name_en"], + "param_category": r["param_category"], + "data_type": r["data_type"], + "unit": r["unit"], + "validation_rules": r["validation_rules"] or {}, + "source_field": r["source_field"], + "sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0, + "required": bool(r["typ_required"]) if r["typ_required"] is not None else False, + "ui_group": r["typ_ui_group"], + } + else: + if r["typ_sort"] is not None: + base["sort_order"] = r["typ_sort"] + if r["typ_required"] is not None: + base["required"] = bool(r["typ_required"]) + if r["typ_ui_group"] is not None: + base["ui_group"] = r["typ_ui_group"] + + out = list(merged.values()) + out.sort(key=lambda x: (x["sort_order"], x["key"])) + return out + + +def resolve_activity_attribute_schema( + cur, + training_category: Optional[str], + training_type_id: Optional[int], +) -> List[Dict[str, Any]]: + """ + Merged parameter definitions for UI / validation (category base + type overrides/additions). + Sorted by sort_order, then key. + """ + cat = _effective_training_category(cur, training_category, training_type_id) + category_rows: List[Dict[str, Any]] = [] + type_rows: List[Dict[str, Any]] = [] + + if cat: + cur.execute( + """ + SELECT + tcp.training_parameter_id, + tcp.sort_order AS cat_sort, + tcp.required AS cat_required, + tcp.ui_group AS cat_ui_group, + tp.key, tp.name_de, tp.name_en, tp.category AS param_category, + tp.data_type, tp.unit, tp.validation_rules, tp.source_field + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + WHERE tcp.training_category = %s AND tp.is_active = true + """, + (cat,), + ) + category_rows = list(cur.fetchall()) + + if training_type_id is not None: + cur.execute( + """ + SELECT + ttp.training_parameter_id, + ttp.sort_order AS typ_sort, + ttp.required AS typ_required, + ttp.ui_group AS typ_ui_group, + tp.key, tp.name_de, tp.name_en, tp.category AS param_category, + tp.data_type, tp.unit, tp.validation_rules, tp.source_field + FROM training_type_parameter ttp + JOIN training_parameters tp ON tp.id = ttp.training_parameter_id + WHERE ttp.training_type_id = %s AND tp.is_active = true + """, + (training_type_id,), + ) + type_rows = list(cur.fetchall()) + + return merge_parameter_schema_rows(category_rows, type_rows) + + +def _validation_rules_dict(raw: Any) -> Dict[str, Any]: + if isinstance(raw, dict): + return raw + return {} + + +def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None: + if data_type == "integer": + if not isinstance(value, int) or isinstance(value, bool): + raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}") + if "min" in rules and value < rules["min"]: + raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})") + if "max" in rules and value > rules["max"]: + raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})") + elif data_type == "float": + if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)): + raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}") + v = float(value) + if "min" in rules and v < float(rules["min"]): + raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})") + if "max" in rules and v > float(rules["max"]): + raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})") + elif data_type == "string": + if not isinstance(value, str): + raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}") + if rules.get("not_empty") and not value.strip(): + raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt") + if "max_length" in rules and len(value) > int(rules["max_length"]): + raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})") + allowed = rules.get("allowed_values") + if allowed and value not in allowed: + raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge") + elif data_type == "boolean": + if not isinstance(value, bool): + raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}") + else: + raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}") + + +def _row_value_tuple(data_type: str, value: Any) -> tuple: + if data_type == "integer": + return (None, int(value), None, None) + if data_type == "float": + return (float(value), None, None, None) + if data_type == "string": + return (None, None, str(value), None) + if data_type == "boolean": + return (None, None, None, bool(value)) + raise ValueError(data_type) + + +def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]: + cur.execute( + """ + SELECT + m.id, + m.activity_log_id, + m.training_parameter_id, + m.value_num, + m.value_int, + m.value_text, + m.value_bool, + tp.key, + tp.data_type, + tp.unit + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id = %s + ORDER BY tp.key + """, + (activity_log_id,), + ) + rows = cur.fetchall() + out: List[Dict[str, Any]] = [] + for r in rows: + dt = r["data_type"] + if dt == "integer": + val = int(r["value_int"]) if r["value_int"] is not None else None + elif dt == "float": + val = float(r["value_num"]) if r["value_num"] is not None else None + elif dt == "string": + val = r["value_text"] + else: + val = r["value_bool"] + out.append( + { + "training_parameter_id": r["training_parameter_id"], + "key": r["key"], + "data_type": dt, + "unit": r["unit"], + "value": val, + } + ) + return out + + +def replace_activity_session_metrics( + cur, + profile_id: str, + activity_log_id: str, + metrics: Sequence[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """ + Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...] + """ + cur.execute( + """ + SELECT id, profile_id, training_category, training_type_id + FROM activity_log WHERE id = %s + """, + (activity_log_id,), + ) + row = cur.fetchone() + if not row or str(row["profile_id"]) != str(profile_id): + raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden") + + schema = resolve_activity_attribute_schema( + cur, row.get("training_category"), row.get("training_type_id") + ) + by_key = {s["key"]: s for s in schema} + payload_keys = set() + for item in metrics: + raw_k = item.get("parameter_key") + if raw_k is None or not str(raw_k).strip(): + raise ActivitySessionMetricsError(400, "parameter_key fehlt") + k = str(raw_k).strip() + if k not in by_key: + raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}") + payload_keys.add(k) + + for s in schema: + if s["required"] and s["key"] not in payload_keys: + raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {s['key']}") + + cur.execute( + "DELETE FROM activity_session_metrics WHERE activity_log_id = %s", + (activity_log_id,), + ) + + for item in metrics: + k = str(item["parameter_key"]).strip() + spec = by_key[k] + rules = _validation_rules_dict(spec["validation_rules"]) + _validate_single_value(spec["data_type"], item.get("value"), rules) + vn, vi, vt, vb = _row_value_tuple(spec["data_type"], item["value"]) + 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()) + """, + (activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb), + ) + + return fetch_activity_session_metrics(cur, activity_log_id) + + +def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]: + 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): + raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden") + + header = dict(row) + schema = resolve_activity_attribute_schema( + cur, header.get("training_category"), header.get("training_type_id") + ) + metrics = fetch_activity_session_metrics(cur, activity_log_id) + return { + "header": header, + "schema": schema, + "metrics": metrics, + } + + +def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None: + """Mutates each session dict: adds key 'session_metrics' (list) when sessions non-empty.""" + if not sessions: + return + ids = [str(s["id"]) for s in sessions if s.get("id")] + if not ids: + return + ph = ",".join(["%s"] * len(ids)) + cur.execute( + f""" + SELECT + m.activity_log_id, + tp.key, + tp.data_type, + tp.unit, + m.value_num, + m.value_int, + m.value_text, + m.value_bool + FROM activity_session_metrics m + JOIN training_parameters tp ON tp.id = m.training_parameter_id + WHERE m.activity_log_id IN ({ph}) + ORDER BY m.activity_log_id, tp.key + """, + ids, + ) + by_act: Dict[str, List[Dict[str, Any]]] = {} + for r in cur.fetchall(): + aid = str(r["activity_log_id"]) + dt = r["data_type"] + if dt == "integer": + val = int(r["value_int"]) if r["value_int"] is not None else None + elif dt == "float": + val = float(r["value_num"]) if r["value_num"] is not None else None + elif dt == "string": + val = r["value_text"] + else: + val = r["value_bool"] + by_act.setdefault(aid, []).append( + {"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val} + ) + for s in sessions: + aid = str(s.get("id")) + s["session_metrics"] = by_act.get(aid, []) diff --git a/backend/main.py b/backend/main.py index 825cdb8..3a0826b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -36,6 +36,7 @@ from routers import reference_values # Persönliche Referenzwerte (Profil) from routers import admin_reference_value_types # Admin: Referenzwert-Typen from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser +from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -128,6 +129,8 @@ app.include_router(admin_reference_value_types.router) # /api/admin/reference-v app.include_router(app_dashboard.router) # /api/app/dashboard-layout app.include_router(csv_import.router) # /api/csv/* (Issue #21) app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21) +app.include_router(admin_training_parameters.router) # /api/admin/training-parameters +app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/054_activity_session_metrics_eav.sql b/backend/migrations/054_activity_session_metrics_eav.sql new file mode 100644 index 0000000..18bc421 --- /dev/null +++ b/backend/migrations/054_activity_session_metrics_eav.sql @@ -0,0 +1,80 @@ +-- Migration 054: Activity session metrics (EAV) + attribute profiles +-- Date: 2026-04-14 +-- Additive only: safe for production (no data deletion). +-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md + +-- Session interval (nullable; optional backfill later) +ALTER TABLE activity_log + ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started + ON activity_log (profile_id, started_at DESC) + WHERE started_at IS NOT NULL; + +COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis'; +COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional'; + +-- Which parameters apply to which training category (training_types.category) +CREATE TABLE IF NOT EXISTS training_category_parameter ( + id SERIAL PRIMARY KEY, + training_category VARCHAR(50) NOT NULL, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE, + sort_order INT NOT NULL DEFAULT 0, + required BOOLEAN NOT NULL DEFAULT false, + ui_group VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id) +); + +CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category); + +COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category'; + +-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present) +CREATE TABLE IF NOT EXISTS training_type_parameter ( + id SERIAL PRIMARY KEY, + training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE, + sort_order INT, + required BOOLEAN, + ui_group VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id) +); + +CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id); + +COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row'; + +-- EAV values per activity session +CREATE TABLE IF NOT EXISTS activity_session_metrics ( + id BIGSERIAL PRIMARY KEY, + activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE, + training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT, + value_num DOUBLE PRECISION, + value_int BIGINT, + value_text TEXT, + value_bool BOOLEAN, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id), + CONSTRAINT chk_activity_session_metric_one_value CHECK ( + ( + (value_num IS NOT NULL)::int + + (value_int IS NOT NULL)::int + + (value_text IS NOT NULL)::int + + (value_bool IS NOT NULL)::int + ) = 1 + ) +); + +CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id); +CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id); + +COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps'; +END $$; diff --git a/backend/models.py b/backend/models.py index c2b473a..8be2d09 100644 --- a/backend/models.py +++ b/backend/models.py @@ -3,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API Data validation schemas for request/response bodies. """ -from typing import Optional -from pydantic import BaseModel +from typing import Any, List, Optional + +from pydantic import BaseModel, Field # ── Profile Models ──────────────────────────────────────────────────────────── @@ -91,6 +92,17 @@ class ActivityEntry(BaseModel): training_subcategory: Optional[str] = None # v9d: Denormalized subcategory +class ActivityMetricValue(BaseModel): + parameter_key: str + value: Any + + +class ActivityMetricsReplace(BaseModel): + """Voller Ersatz der EAV-Metriken für eine Session (siehe Agent-Guide).""" + + metrics: List[ActivityMetricValue] = Field(default_factory=list) + + class NutritionDay(BaseModel): date: str kcal: Optional[float] = None diff --git a/backend/placeholder_registrations/activity_session_insights.py b/backend/placeholder_registrations/activity_session_insights.py index bb78f50..faef32a 100644 --- a/backend/placeholder_registrations/activity_session_insights.py +++ b/backend/placeholder_registrations/activity_session_insights.py @@ -130,15 +130,17 @@ def register_activity_session_insights(): key="training_sessions_recent_json", category="Aktivität", description=( - "JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie)" + "JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, " + "session_id, session_metrics[] aus EAV)" ), resolver_module="backend/placeholder_resolver.py", resolver_function="_safe_json", data_layer_module="backend/data_layer/activity_metrics.py", data_layer_function="get_training_sessions_recent_weeks_data", - source_tables=["activity_log", "training_types"], + source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"], semantic_contract=( - "Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. " + "Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung; " + "session_metrics[] = Layer-1-EAV-Werte (key, data_type, unit, value) wenn konfiguriert/gespeichert. " "Default 4 ISO-Wochen zurück." ), business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI", @@ -158,7 +160,8 @@ def register_activity_session_insights(): legacy_display="{}", ), known_limitations=( - "Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id." + "Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id. " + "session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind." ), layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data", layer_2a_decision="_safe_json('training_sessions_recent_json')", diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 40966fc..ec7f014 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage -from models import ActivityEntry +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 @@ -177,6 +177,57 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None), return {"ok":True} +@router.put("/{eid}/metrics") +def replace_activity_metrics( + eid: str, + body: ActivityMetricsReplace, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md). + """ + from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + replace_activity_session_metrics, + ) + + pid = get_pid(x_profile_id) + payload = [m.model_dump() for m in body.metrics] + try: + with get_db() as conn: + cur = get_cursor(conn) + metrics = replace_activity_session_metrics(cur, pid, eid, payload) + conn.commit() + except ActivitySessionMetricsError as err: + raise HTTPException(err.status_code, err.detail) from err + return {"id": eid, "metrics": 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).""" + from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + get_activity_session_logical_unit, + ) + from data_layer.utils import serialize_dates + + pid = get_pid(x_profile_id) + try: + with get_db() as conn: + cur = get_cursor(conn) + unit = get_activity_session_logical_unit(cur, pid, eid) + except ActivitySessionMetricsError as err: + raise HTTPException(err.status_code, err.detail) from err + unit["header"] = serialize_dates(unit["header"]) + return unit + + @router.get("/stats") def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get activity statistics (last 30 entries).""" diff --git a/backend/routers/admin_activity_attribute_profiles.py b/backend/routers/admin_activity_attribute_profiles.py new file mode 100644 index 0000000..d955956 --- /dev/null +++ b/backend/routers/admin_activity_attribute_profiles.py @@ -0,0 +1,184 @@ +""" +Admin: training_category_parameter + training_type_parameter (attribute profiles). + +Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +from auth import require_admin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin", tags=["admin", "activity-attribute-profiles"]) + + +class CategoryParameterCreate(BaseModel): + training_category: str = Field(..., min_length=1, max_length=50) + training_parameter_id: int + sort_order: int = 0 + required: bool = False + ui_group: Optional[str] = Field(None, max_length=50) + + +class TypeParameterCreate(BaseModel): + training_type_id: int + training_parameter_id: int + sort_order: Optional[int] = None + required: Optional[bool] = None + ui_group: Optional[str] = Field(None, max_length=50) + + +@router.get("/training-category-parameters") +def admin_list_category_parameters( + category: Optional[str] = Query(None, description="Filter: training_types.category"), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + if category: + cur.execute( + """ + SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + WHERE tcp.training_category = %s + ORDER BY tcp.sort_order, tp.key + """, + (category.strip(),), + ) + else: + cur.execute( + """ + SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_category_parameter tcp + JOIN training_parameters tp ON tp.id = tcp.training_parameter_id + ORDER BY tcp.training_category, tcp.sort_order, tp.key + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/training-category-parameters") +def admin_add_category_parameter( + body: CategoryParameterCreate, + session: dict = Depends(require_admin), +): + cat = body.training_category.strip() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_parameter_id unbekannt") + try: + cur.execute( + """ + INSERT INTO training_category_parameter ( + training_category, training_parameter_id, sort_order, required, ui_group + ) VALUES (%s,%s,%s,%s,%s) + RETURNING id + """, + (cat, body.training_parameter_id, body.sort_order, body.required, body.ui_group), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except Exception as e: + conn.rollback() + if "uq_training_category_parameter" in str(e).lower() or "unique" in str(e).lower(): + raise HTTPException(409, "Zuordnung existiert bereits") from e + raise HTTPException(400, str(e)) from e + return {"id": new_id} + + +@router.delete("/training-category-parameters/{link_id}") +def admin_delete_category_parameter( + link_id: int, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM training_category_parameter WHERE id = %s RETURNING id", + (link_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True} + + +@router.get("/training-type-parameters") +def admin_list_type_parameters( + training_type_id: int = Query(..., ge=1), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT ttp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de + FROM training_type_parameter ttp + JOIN training_parameters tp ON tp.id = ttp.training_parameter_id + WHERE ttp.training_type_id = %s + ORDER BY ttp.sort_order NULLS LAST, tp.key + """, + (training_type_id,), + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("/training-type-parameters") +def admin_add_type_parameter( + body: TypeParameterCreate, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM training_types WHERE id = %s", (body.training_type_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_type_id unbekannt") + cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,)) + if not cur.fetchone(): + raise HTTPException(404, "training_parameter_id unbekannt") + try: + cur.execute( + """ + INSERT INTO training_type_parameter ( + training_type_id, training_parameter_id, sort_order, required, ui_group + ) VALUES (%s,%s,%s,%s,%s) + RETURNING id + """, + ( + body.training_type_id, + body.training_parameter_id, + body.sort_order, + body.required, + body.ui_group, + ), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except Exception as e: + conn.rollback() + if "uq_training_type_parameter" in str(e).lower() or "unique" in str(e).lower(): + raise HTTPException(409, "Zuordnung existiert bereits") from e + raise HTTPException(400, str(e)) from e + return {"id": new_id} + + +@router.delete("/training-type-parameters/{link_id}") +def admin_delete_type_parameter( + link_id: int, + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM training_type_parameter WHERE id = %s RETURNING id", + (link_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True} diff --git a/backend/routers/admin_training_parameters.py b/backend/routers/admin_training_parameters.py new file mode 100644 index 0000000..43dbf00 --- /dev/null +++ b/backend/routers/admin_training_parameters.py @@ -0,0 +1,215 @@ +""" +Admin: training_parameters catalog (EAV keys for activity session metrics). + +Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +""" +import re +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field +from psycopg2 import errors as pg_errors +from psycopg2.extras import Json + +from auth import require_admin +from db import get_db, get_cursor, r2d + +router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"]) + +KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$") + +PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"} +DATA_TYPES = {"integer", "float", "string", "boolean"} + + +class TrainingParameterCreate(BaseModel): + key: str = Field(..., min_length=1, max_length=50) + name_de: str = Field(..., min_length=1, max_length=100) + name_en: str = Field(..., min_length=1, max_length=100) + category: str = Field(..., max_length=50) + data_type: str = Field(..., max_length=20) + unit: Optional[str] = Field(None, max_length=20) + description_de: Optional[str] = None + description_en: Optional[str] = None + source_field: Optional[str] = Field(None, max_length=100) + validation_rules: Optional[dict] = None + is_active: bool = True + + +class TrainingParameterUpdate(BaseModel): + name_de: Optional[str] = Field(None, min_length=1, max_length=100) + name_en: Optional[str] = Field(None, min_length=1, max_length=100) + category: Optional[str] = Field(None, max_length=50) + data_type: Optional[str] = Field(None, max_length=20) + unit: Optional[str] = Field(None, max_length=20) + description_de: Optional[str] = None + description_en: Optional[str] = None + source_field: Optional[str] = Field(None, max_length=100) + validation_rules: Optional[dict] = None + is_active: Optional[bool] = None + + +def _norm_key(key: str) -> str: + k = key.strip().lower() + if not KEY_PATTERN.match(k): + raise HTTPException( + 400, + "Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.", + ) + return k + + +def _validate_category(cat: str) -> str: + c = cat.strip() + if c not in PARAM_CATEGORY: + raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein") + return c + + +def _validate_data_type(dt: str) -> str: + d = dt.strip().lower() + if d not in DATA_TYPES: + raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein") + return d + + +@router.get("") +def admin_list_training_parameters( + include_inactive: bool = Query(False), + session: dict = Depends(require_admin), +): + with get_db() as conn: + cur = get_cursor(conn) + if include_inactive: + cur.execute( + """ + SELECT * FROM training_parameters + ORDER BY category, key + """ + ) + else: + cur.execute( + """ + SELECT * FROM training_parameters + WHERE is_active = true + ORDER BY category, key + """ + ) + return [r2d(r) for r in cur.fetchall()] + + +@router.post("") +def admin_create_training_parameter( + body: TrainingParameterCreate, + session: dict = Depends(require_admin), +): + key = _norm_key(body.key) + cat = _validate_category(body.category) + dt = _validate_data_type(body.data_type) + rules = body.validation_rules if body.validation_rules is not None else {} + + with get_db() as conn: + cur = get_cursor(conn) + try: + cur.execute( + """ + INSERT INTO training_parameters ( + key, name_de, name_en, category, data_type, unit, + description_de, description_en, source_field, validation_rules, is_active + ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, + ( + key, + body.name_de.strip(), + body.name_en.strip(), + cat, + dt, + body.unit.strip() if body.unit else None, + body.description_de, + body.description_en, + body.source_field.strip() if body.source_field else None, + Json(rules), + body.is_active, + ), + ) + new_id = cur.fetchone()["id"] + conn.commit() + except pg_errors.UniqueViolation: + conn.rollback() + raise HTTPException(409, "Parameter-key existiert bereits") from None + return {"id": new_id, "key": key} + + +@router.put("/{param_id}") +def admin_update_training_parameter( + param_id: int, + body: TrainingParameterUpdate, + session: dict = Depends(require_admin), +): + cols: list[str] = [] + vals: list[Any] = [] + + if body.name_de is not None: + cols.append("name_de = %s") + vals.append(body.name_de.strip()) + if body.name_en is not None: + cols.append("name_en = %s") + vals.append(body.name_en.strip()) + if body.category is not None: + cols.append("category = %s") + vals.append(_validate_category(body.category)) + if body.data_type is not None: + cols.append("data_type = %s") + vals.append(_validate_data_type(body.data_type)) + if body.unit is not None: + cols.append("unit = %s") + vals.append(body.unit.strip() or None) + if body.description_de is not None: + cols.append("description_de = %s") + vals.append(body.description_de) + if body.description_en is not None: + cols.append("description_en = %s") + vals.append(body.description_en) + if body.source_field is not None: + cols.append("source_field = %s") + vals.append(body.source_field.strip() or None) + if body.validation_rules is not None: + cols.append("validation_rules = %s") + vals.append(Json(body.validation_rules)) + if body.is_active is not None: + cols.append("is_active = %s") + vals.append(body.is_active) + + if not cols: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + + vals.append(param_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id", + vals, + ) + if not cur.fetchone(): + raise HTTPException(404, "Parameter nicht gefunden") + conn.commit() + return {"ok": True, "id": param_id} + + +@router.delete("/{param_id}") +def admin_deactivate_training_parameter( + param_id: int, + session: dict = Depends(require_admin), +): + """Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id", + (param_id,), + ) + if not cur.fetchone(): + raise HTTPException(404, "Parameter nicht gefunden") + conn.commit() + return {"ok": True, "id": param_id} diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py new file mode 100644 index 0000000..930ec21 --- /dev/null +++ b/backend/tests/test_activity_session_metrics.py @@ -0,0 +1,204 @@ +"""Unit tests for data_layer.activity_session_metrics (no DB for most cases).""" + +import uuid + +import pytest + +from data_layer.activity_session_metrics import ( + ActivitySessionMetricsError, + enrich_sessions_with_metrics, + merge_parameter_schema_rows, + resolve_activity_attribute_schema, + _row_value_tuple, + _validate_single_value, +) + + +def _tp_row( + pid: int, + key: str, + *, + data_type: str = "integer", + cat_sort: int = 0, + cat_required: bool = False, + name_de: str = "X", + name_en: str = "X", + param_category: str = "physical", + unit: str | None = None, + validation_rules: dict | None = None, + source_field: str | None = None, +): + return { + "training_parameter_id": pid, + "cat_sort": cat_sort, + "cat_required": cat_required, + "cat_ui_group": None, + "key": key, + "name_de": name_de, + "name_en": name_en, + "param_category": param_category, + "data_type": data_type, + "unit": unit, + "validation_rules": validation_rules or {}, + "source_field": source_field, + } + + +def _ttp_row( + pid: int, + key: str, + *, + typ_sort: int | None = None, + typ_required: bool | None = None, + typ_ui_group: str | None = None, + data_type: str = "integer", + name_de: str = "X", + name_en: str = "X", + param_category: str = "physical", + unit: str | None = None, + validation_rules: dict | None = None, + source_field: str | None = None, +): + return { + "training_parameter_id": pid, + "typ_sort": typ_sort, + "typ_required": typ_required, + "typ_ui_group": typ_ui_group, + "key": key, + "name_de": name_de, + "name_en": name_en, + "param_category": param_category, + "data_type": data_type, + "unit": unit, + "validation_rules": validation_rules or {}, + "source_field": source_field, + } + + +def test_merge_category_only_sorted_by_sort_order_then_key(): + cat = [ + _tp_row(2, "zebra", cat_sort=10), + _tp_row(1, "alpha", cat_sort=5), + ] + merged = merge_parameter_schema_rows(cat, []) + assert [m["key"] for m in merged] == ["alpha", "zebra"] + assert merged[0]["required"] is False + + +def test_merge_type_overrides_required_and_sort(): + cat = [_tp_row(1, "rpe", cat_sort=0, cat_required=False)] + typ = [_ttp_row(1, "rpe", typ_sort=99, typ_required=True)] + merged = merge_parameter_schema_rows(cat, typ) + assert len(merged) == 1 + assert merged[0]["sort_order"] == 99 + assert merged[0]["required"] is True + + +def test_merge_type_adds_parameter_not_in_category(): + typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")] + merged = merge_parameter_schema_rows([], typ) + assert len(merged) == 1 + assert merged[0]["key"] == "cadence" + assert merged[0]["required"] is True + + +def test_validate_integer_range(): + _validate_single_value("integer", 5, {"min": 1, "max": 10}) + with pytest.raises(ActivitySessionMetricsError) as ei: + _validate_single_value("integer", 0, {"min": 1}) + assert ei.value.status_code == 400 + + +def test_validate_float_accepts_int(): + _validate_single_value("float", 3, {"min": 0, "max": 10}) + + +def test_validate_boolean_rejects_int(): + with pytest.raises(ActivitySessionMetricsError): + _validate_single_value("boolean", 1, {}) + + +def test_row_value_tuple_mapping(): + assert _row_value_tuple("integer", 42) == (None, 42, None, None) + assert _row_value_tuple("float", 1.5) == (1.5, None, None, None) + assert _row_value_tuple("string", "hi") == (None, None, "hi", None) + assert _row_value_tuple("boolean", True) == (None, None, None, True) + + +class _FakeCursor: + """Sequences fetchone/fetchall for resolve_activity_attribute_schema.""" + + def __init__(self, fetchone_chain, fetchall_chain): + self._fetchone = list(fetchone_chain) + self._fetchall = list(fetchall_chain) + self.executes: list[tuple] = [] + + def execute(self, sql, params=None): + self.executes.append((sql, params)) + + def fetchone(self): + return self._fetchone.pop(0) + + def fetchall(self): + return self._fetchall.pop(0) + + +def test_resolve_with_explicit_category_no_type(): + cur = _FakeCursor( + fetchone_chain=[], + fetchall_chain=[ + [ + _tp_row(1, "rpe", cat_sort=0), + ], + ], + ) + out = resolve_activity_attribute_schema(cur, "cardio", None) + assert len(out) == 1 + assert out[0]["key"] == "rpe" + assert len(cur.executes) == 1 + + +def test_resolve_loads_category_from_training_type_id(): + cur = _FakeCursor( + fetchone_chain=[{"category": "strength"}], + fetchall_chain=[ + [_tp_row(1, "rpe", cat_sort=0)], + [], + ], + ) + out = resolve_activity_attribute_schema(cur, None, 42) + assert len(out) == 1 + assert cur.executes[0][1] == (42,) + + +def test_enrich_sessions_batch(): + aid = str(uuid.uuid4()) + bid = str(uuid.uuid4()) + + class _Cur: + def __init__(self): + self.params = None + + def execute(self, sql, params=None): + self.sql = sql + self.params = params + + def fetchall(self): + return [ + { + "activity_log_id": uuid.UUID(aid), + "key": "rpe", + "data_type": "integer", + "unit": None, + "value_num": None, + "value_int": 7, + "value_text": None, + "value_bool": None, + }, + ] + + 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[1]["session_metrics"] == [] From cf7379b2f67085e8861fe71d9a1cb7dc04f92edc Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 11:56:16 +0200 Subject: [PATCH 02/17] feat: Implement Activity Attribute Profiles and session metrics editing - Added new Admin UI for managing Activity Attribute Profiles. - Enhanced ActivityPage to support dynamic loading and editing of session metrics. - Updated API utility functions to handle new endpoints for training parameters and metrics. - Improved form handling for session metrics, including validation and error management. - Updated documentation to reflect new features and changes in session metrics handling. --- ...CTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md | 4 +- CLAUDE.md | 1 + frontend/src/App.jsx | 2 + frontend/src/config/adminNav.js | 5 + frontend/src/pages/ActivityPage.jsx | 181 +++++- .../AdminActivityAttributeProfilesPage.jsx | 594 ++++++++++++++++++ frontend/src/utils/api.js | 24 + 7 files changed, 803 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/AdminActivityAttributeProfilesPage.jsx diff --git a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md index c0581bf..186f46c 100644 --- a/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md +++ b/.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md @@ -81,8 +81,8 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a ## 5. Agent-Checkliste (nächste Iterationen) -- [ ] Admin-UI: Matrix Kategorie / Trainingstyp ↔ Parameter. -- [ ] `/activity` Frontend: dynamische Felder aus `GET /api/activity/{id}`. +- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“. +- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`. - [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor). - [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`. - [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue). diff --git a/CLAUDE.md b/CLAUDE.md index 0de72bb..014823a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,6 +121,7 @@ frontend/src/ - **Agent-Guide:** `.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Prod: nur additive Migration **054**; Layer1 `data_layer/activity_session_metrics.py`). - **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable). - **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt). +- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt. ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f9a5fd2..a5b44a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage' import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' +import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage' import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' @@ -255,6 +256,7 @@ function AppShell() { } /> }/> }/> + } /> }/> }/> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index d87cf5c..848306f 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [ label: 'Trainings-Profile', description: 'Training-Type-Profile (#15).', }, + { + to: '/admin/activity-attribute-profiles', + label: 'Session-Metriken (EAV)', + description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.', + }, ], }, { diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 759bdf2..e68054b 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -27,6 +27,80 @@ function empty() { } } +function buildMetricsPayload(schema, draft) { + const out = [] + for (const s of schema) { + const raw = draft[s.key] + if (s.data_type === 'boolean') { + if (raw === '' || raw === null || raw === undefined) { + if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) + continue + } + out.push({ parameter_key: s.key, value: !!raw }) + continue + } + if (raw === '' || raw === null || raw === undefined) { + if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) + continue + } + let v = raw + if (s.data_type === 'integer') { + v = parseInt(String(raw), 10) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) + } else if (s.data_type === 'float') { + v = parseFloat(String(raw)) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) + } else { + v = String(raw) + } + out.push({ parameter_key: s.key, value: v }) + } + return out +} + +function SessionMetricsFields({ schema, values, setValues }) { + if (!schema || schema.length === 0) return null + const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) + return ( +
+
Weitere Kennwerte (Profil)
+ {schema.map((s) => ( +
+ + {s.data_type === 'boolean' ? ( + set(s.key, e.target.checked)} + /> + ) : s.data_type === 'integer' || s.data_type === 'float' ? ( + set(s.key, e.target.value)} + /> + ) : ( + set(s.key, e.target.value)} + /> + )} + +
+ ))} +
+ ) +} + // ── Import Panel ────────────────────────────────────────────────────────────── function ImportPanel({ onImported }) { const fileRef = useRef() @@ -85,7 +159,17 @@ function ImportPanel({ onImported }) { } // ── Manual Entry ────────────────────────────────────────────────────────────── -function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) { +function EntryForm({ + form, + setForm, + onSave, + onCancel, + saveLabel = 'Speichern', + saving = false, + error = null, + usage = null, + formExtras = null, +}) { const set = (k,v) => setForm(f=>({...f,[k]:v})) return (
@@ -144,6 +228,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
+ {formExtras} {error && (
{error} @@ -181,6 +266,10 @@ export default function ActivityPage() { const [error, setError] = useState(null) const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge const [categories, setCategories] = useState({}) // v9d: Training categories + const [sessionDetail, setSessionDetail] = useState(null) + const [metricDraft, setMetricDraft] = useState({}) + const [sessionLoadError, setSessionLoadError] = useState(null) + const [savingEdit, setSavingEdit] = useState(false) const load = async () => { const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) @@ -200,6 +289,46 @@ export default function ActivityPage() { api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) },[]) + useEffect(() => { + if (!editing?.id) { + setSessionDetail(null) + setMetricDraft({}) + setSessionLoadError(null) + return + } + let cancelled = false + setSessionLoadError(null) + ;(async () => { + try { + const d = await api.getActivitySession(editing.id) + if (!cancelled) setSessionDetail(d) + } catch (err) { + if (!cancelled) { + setSessionDetail(null) + setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden') + } + } + })() + return () => { cancelled = true } + }, [editing?.id]) + + useEffect(() => { + if (!sessionDetail) { + setMetricDraft({}) + return + } + const m = {} + for (const row of sessionDetail.metrics || []) { + m[row.key] = row.value + } + for (const s of sessionDetail.schema || []) { + if (!(s.key in m)) { + m[s.key] = s.data_type === 'boolean' ? false : '' + } + } + setMetricDraft(m) + }, [sessionDetail]) + const handleSave = async () => { setSaving(true) setError(null) @@ -226,9 +355,30 @@ export default function ActivityPage() { } const handleUpdate = async () => { - const payload = {...editing} - await api.updateActivity(editing.id, payload) - setEditing(null); await load() + setSavingEdit(true) + setError(null) + try { + const payload = { ...editing } + delete payload.id + 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) + if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max) + if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10) + await api.updateActivity(editing.id, payload) + if (sessionDetail?.schema?.length > 0) { + const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft) + await api.putActivityMetrics(editing.id, { metrics }) + } + setEditing(null) + setSessionDetail(null) + await load() + } catch (err) { + setError(err.message || 'Speichern fehlgeschlagen') + setTimeout(() => setError(null), 6000) + } finally { + setSavingEdit(false) + } } const handleDelete = async (id) => { @@ -347,8 +497,27 @@ export default function ActivityPage() { return (
{isEd ? ( - setEditing(null)} saveLabel="Speichern"/> + { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }} + saveLabel="Speichern" + saving={savingEdit} + error={error} + formExtras={ + <> + {sessionLoadError && ( +
{sessionLoadError}
+ )} + + + } + /> ) : (
diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx new file mode 100644 index 0000000..3fae2b4 --- /dev/null +++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx @@ -0,0 +1,594 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { Plus, Trash2, Save, RefreshCw } from 'lucide-react' +import { api } from '../utils/api' + +const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance'] +const DATA_TYPES = ['integer', 'float', 'string', 'boolean'] + +const emptyParamForm = () => ({ + key: '', + name_de: '', + name_en: '', + category: 'physical', + data_type: 'float', + unit: '', + source_field: '', + is_active: true, +}) + +export default function AdminActivityAttributeProfilesPage() { + const [params, setParams] = useState([]) + const [includeInactive, setIncludeInactive] = useState(false) + const [catMeta, setCatMeta] = useState({}) + const [flatTypes, setFlatTypes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [toast, setToast] = useState(null) + + const [showParamForm, setShowParamForm] = useState(false) + const [paramForm, setParamForm] = useState(emptyParamForm()) + + const [selCategory, setSelCategory] = useState('cardio') + const [catLinks, setCatLinks] = useState([]) + const [catAdd, setCatAdd] = useState({ + training_parameter_id: '', + sort_order: 0, + required: false, + ui_group: '', + }) + + const [selTypeId, setSelTypeId] = useState('') + const [typeLinks, setTypeLinks] = useState([]) + const [typeAdd, setTypeAdd] = useState({ + training_parameter_id: '', + sort_order: '', + required: '', + ui_group: '', + }) + + const showToast = (msg) => { + setToast(msg) + setTimeout(() => setToast(null), 2800) + } + + const refreshCatalog = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [p, cats, flat] = await Promise.all([ + api.adminListTrainingParameters(includeInactive), + api.getTrainingCategories(), + api.listTrainingTypesFlat(), + ]) + setParams(Array.isArray(p) ? p : []) + setCatMeta(cats && typeof cats === 'object' ? cats : {}) + setFlatTypes(Array.isArray(flat) ? flat : []) + setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : '')) + } catch (e) { + setError(e.message || 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, [includeInactive]) + + useEffect(() => { + refreshCatalog() + }, [refreshCatalog]) + + const loadCatLinks = useCallback(async () => { + try { + const data = await api.adminListTrainingCategoryParameters(selCategory) + setCatLinks(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Kategorie-Zuordnungen') + } + }, [selCategory]) + + useEffect(() => { + loadCatLinks() + }, [loadCatLinks]) + + const loadTypeLinks = useCallback(async () => { + if (!selTypeId) { + setTypeLinks([]) + return + } + try { + const data = await api.adminListTrainingTypeParameters(Number(selTypeId)) + setTypeLinks(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Typ-Zuordnungen') + } + }, [selTypeId]) + + useEffect(() => { + loadTypeLinks() + }, [loadTypeLinks]) + + const activeParams = params.filter((p) => p.is_active !== false) + + const saveNewParameter = async () => { + setError(null) + if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) { + setError('key, name_de und name_en sind Pflicht.') + return + } + try { + await api.adminCreateTrainingParameter({ + key: paramForm.key.trim().toLowerCase(), + name_de: paramForm.name_de.trim(), + name_en: paramForm.name_en.trim(), + category: paramForm.category, + data_type: paramForm.data_type, + unit: paramForm.unit.trim() || null, + source_field: paramForm.source_field.trim() || null, + is_active: paramForm.is_active, + validation_rules: {}, + }) + showToast('Parameter angelegt') + setShowParamForm(false) + setParamForm(emptyParamForm()) + await refreshCatalog() + } catch (e) { + setError(e.message || 'Speichern fehlgeschlagen') + } + } + + const deactivateParameter = async (id) => { + if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return + try { + await api.adminDeleteTrainingParameter(id) + showToast('Deaktiviert') + await refreshCatalog() + } catch (e) { + setError(e.message || 'Löschen fehlgeschlagen') + } + } + + const addCatLink = async () => { + const pid = parseInt(catAdd.training_parameter_id, 10) + if (!pid) { + setError('Parameter-ID wählen') + return + } + try { + await api.adminAddTrainingCategoryParameter({ + training_category: selCategory, + training_parameter_id: pid, + sort_order: Number(catAdd.sort_order) || 0, + required: !!catAdd.required, + ui_group: catAdd.ui_group.trim() || null, + }) + showToast('Zuordnung gespeichert') + setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' }) + await loadCatLinks() + } catch (e) { + setError(e.message || 'Konflikt oder ungültige Daten') + } + } + + const addTypeLink = async () => { + const tid = Number(selTypeId) + const pid = parseInt(typeAdd.training_parameter_id, 10) + if (!tid || !pid) { + setError('Trainingstyp und Parameter wählen') + return + } + const body = { + training_type_id: tid, + training_parameter_id: pid, + sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order), + required: typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true, + ui_group: typeAdd.ui_group.trim() || null, + } + try { + await api.adminAddTrainingTypeParameter(body) + showToast('Typ-Zuordnung gespeichert') + setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' }) + await loadTypeLinks() + } catch (e) { + setError(e.message || 'Konflikt oder ungültige Daten') + } + } + + const categoryKeys = + Object.keys(catMeta).length > 0 + ? Object.keys(catMeta).sort() + : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other'] + + if (loading && !params.length) { + return ( +
+
Lade… +
+ ) + } + + return ( +
+
+ + ← Training (Hub) + +
+

Session-Metriken (EAV)

+

+ Messgrößen-Katalog und Zuordnung zu Kategorie (Basis) bzw.{' '} + Trainingstyp (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer + Aktivität, wenn der Eintrag passend kategorisiert ist. +

+ + {toast && ( +
+ {toast} +
+ )} + {error && ( +
+ {error} + +
+ )} + +
+
+ Parameter-Katalog + + + +
+ + {showParamForm && ( +
+
+ + setParamForm((f) => ({ ...f, key: e.target.value }))} + /> +
+
+ + setParamForm((f) => ({ ...f, name_de: e.target.value }))} + placeholder="DE" + /> + setParamForm((f) => ({ ...f, name_en: e.target.value }))} + placeholder="EN" + /> +
+
+ + + +
+
+ + setParamForm((f) => ({ ...f, unit: e.target.value }))} + /> + setParamForm((f) => ({ ...f, source_field: e.target.value }))} + /> +
+
+ + +
+
+ )} + +
+ + + + + + + + + + + + {params.map((r) => ( + + + + + + + + + ))} + +
IDkeyDETypaktiv +
{r.id} + {r.key} + {r.name_de} + {r.data_type} · {r.category} + {r.is_active === false ? 'nein' : 'ja'} + {r.is_active !== false && ( + + )} +
+
+
+ +
+
Zuordnung: Trainings-Kategorie
+
+ + +
+
+
+ + +
+
+ + setCatAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+ +
+ + setCatAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+ +
+
    + {catLinks.map((l) => ( +
  • + + {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order} + {l.required ? ' · Pflicht' : ''} + {l.ui_group ? ` · ${l.ui_group}` : ''} + + +
  • + ))} +
+
+ +
+
Zuordnung: Trainingstyp (Zusatz / Override)
+
+ + +
+
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+ +
+
    + {typeLinks.map((l) => ( +
  • + + {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '} + {l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'} + + +
  • + ))} +
+
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 205acc2..f09d55c 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -314,6 +314,30 @@ export const api = { adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}), adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'), + // Admin: Training session metrics (EAV) & attribute profiles (Migration 054) + adminListTrainingParameters: (includeInactive = false) => + req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`), + adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)), + adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)), + adminDeleteTrainingParameter: (id) => + req(`/admin/training-parameters/${id}`, { method: 'DELETE' }), + adminListTrainingCategoryParameters: (category = '') => + req( + `/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`, + ), + adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)), + adminDeleteTrainingCategoryParameter: (id) => + req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }), + adminListTrainingTypeParameters: (trainingTypeId) => + req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`), + adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)), + adminDeleteTrainingTypeParameter: (id) => + req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }), + + getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`), + putActivityMetrics: (id, body) => + req(`/activity/${encodeURIComponent(id)}/metrics`, json(body)), + // Sleep Module (v9d Phase 2b) listSleep: (l=90) => req(`/sleep?limit=${l}`), getSleepByDate: (date) => req(`/sleep/by-date/${date}`), From 196b6c5cf185e9906ff85a1ad3cf7032f29fd3ae Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 12:26:52 +0200 Subject: [PATCH 03/17] feat: Add update functionality for training category and type parameters - Introduced new endpoints for updating training category and type parameters in the backend. - Added corresponding update functions in the frontend API utility. - Enhanced the Admin Activity Attribute Profiles page to support editing and saving changes for category and type parameters. - Implemented state management for editing parameters and improved error handling during updates. --- ...tegory_parameter_seed_and_eav_backfill.sql | 213 ++++ backend/routers/activity.py | 22 +- .../admin_activity_attribute_profiles.py | 82 ++ .../AdminActivityAttributeProfilesPage.jsx | 994 ++++++++++++------ frontend/src/utils/api.js | 4 + 5 files changed, 971 insertions(+), 344 deletions(-) create mode 100644 backend/migrations/055_training_category_parameter_seed_and_eav_backfill.sql diff --git a/backend/migrations/055_training_category_parameter_seed_and_eav_backfill.sql b/backend/migrations/055_training_category_parameter_seed_and_eav_backfill.sql new file mode 100644 index 0000000..e5218b0 --- /dev/null +++ b/backend/migrations/055_training_category_parameter_seed_and_eav_backfill.sql @@ -0,0 +1,213 @@ +-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field) +-- + idempotent backfill activity_log → activity_session_metrics (EAV) +-- Date: 2026-04-15 +-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log. +-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md + +--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log). +INSERT INTO training_category_parameter ( + training_category, + training_parameter_id, + sort_order, + required, + ui_group +) +SELECT + tc.training_category, + tp.id, + ROW_NUMBER() OVER ( + PARTITION BY tc.training_category + ORDER BY tp.category, tp.id + ), + false, + NULL +FROM ( + SELECT DISTINCT category AS training_category + FROM training_types + WHERE category IS NOT NULL AND trim(category) <> '' +) tc +CROSS JOIN training_parameters tp +WHERE tp.is_active = true + AND tp.source_field IS NOT NULL + AND trim(tp.source_field) <> '' +ON CONFLICT (training_category, training_parameter_id) DO NOTHING; + +-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert) + +-- duration_min → integer +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT + a.id, + tp.id, + NULL, + ROUND(a.duration_min::numeric)::bigint, + NULL, + NULL, + NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true +WHERE a.duration_min IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +-- distance_km → float +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.distance_km::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true +WHERE a.distance_km IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +-- kcal_active +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true +WHERE a.kcal_active IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true +WHERE a.kcal_resting IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +-- hr_avg / hr_max → keys avg_hr, max_hr +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true +WHERE a.hr_avg IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true +WHERE a.hr_max IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +-- rpe +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.rpe::bigint, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true +WHERE a.rpe IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +-- min_hr (Spalte hr_min nach Migration 014) +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.hr_min, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'min_hr' AND tp.is_active = true +WHERE a.hr_min IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.pace_min_per_km::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'pace_min_per_km' AND tp.is_active = true +WHERE a.pace_min_per_km IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.cadence, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'cadence' AND tp.is_active = true +WHERE a.cadence IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.avg_power, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'avg_power' AND tp.is_active = true +WHERE a.avg_power IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.elevation_gain, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'elevation_gain' AND tp.is_active = true +WHERE a.elevation_gain IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.temperature_celsius::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'temperature_celsius' AND tp.is_active = true +WHERE a.temperature_celsius IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, NULL, a.humidity_percent, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'humidity_percent' AND tp.is_active = true +WHERE a.humidity_percent IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.avg_hr_percent::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'avg_hr_percent' AND tp.is_active = true +WHERE a.avg_hr_percent IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +INSERT INTO activity_session_metrics ( + activity_log_id, training_parameter_id, + value_num, value_int, value_text, value_bool, updated_at +) +SELECT a.id, tp.id, a.kcal_per_km::double precision, NULL, NULL, NULL, NOW() +FROM activity_log a +JOIN training_parameters tp ON tp.key = 'kcal_per_km' AND tp.is_active = true +WHERE a.kcal_per_km IS NOT NULL +ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)'; +END $$; diff --git a/backend/routers/activity.py b/backend/routers/activity.py index ec7f014..492ba24 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -35,18 +35,18 @@ logger = logging.getLogger(__name__) def list_activity( limit: int = Query(200, ge=1, le=50_000), days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"), - x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): """Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*).""" - pid = get_pid(x_profile_id) + # Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet). + pid = str(session["profile_id"]) with get_db() as conn: cur = get_cursor(conn) # Issue #31: Apply global quality filter (profile from DB = saved level) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile) + quality_filter = get_quality_filter_sql(profile or {}) if days is not None: cur.execute( @@ -229,13 +229,23 @@ def get_activity_session( @router.get("/stats") -def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): +def activity_stats(session: dict = Depends(require_auth)): """Get activity statistics (last 30 entries).""" - pid = get_pid(x_profile_id) + pid = str(session["profile_id"]) with get_db() as conn: cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + profile = r2d(cur.fetchone()) + quality_filter = get_quality_filter_sql(profile or {}) cur.execute( - "SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,)) + f""" + SELECT * FROM activity_log + WHERE profile_id=%s {quality_filter} + ORDER BY date DESC + LIMIT 30 + """, + (pid,), + ) rows = [r2d(r) for r in cur.fetchall()] if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}} total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows) diff --git a/backend/routers/admin_activity_attribute_profiles.py b/backend/routers/admin_activity_attribute_profiles.py index d955956..34e1ef9 100644 --- a/backend/routers/admin_activity_attribute_profiles.py +++ b/backend/routers/admin_activity_attribute_profiles.py @@ -30,6 +30,18 @@ class TypeParameterCreate(BaseModel): ui_group: Optional[str] = Field(None, max_length=50) +class CategoryParameterUpdate(BaseModel): + sort_order: Optional[int] = None + required: Optional[bool] = None + ui_group: Optional[str] = Field(None, max_length=50) + + +class TypeParameterUpdate(BaseModel): + sort_order: Optional[int] = None + required: Optional[bool] = None + ui_group: Optional[str] = Field(None, max_length=50) + + @router.get("/training-category-parameters") def admin_list_category_parameters( category: Optional[str] = Query(None, description="Filter: training_types.category"), @@ -91,6 +103,41 @@ def admin_add_category_parameter( return {"id": new_id} +@router.put("/training-category-parameters/{link_id}") +def admin_update_category_parameter( + link_id: int, + body: CategoryParameterUpdate, + session: dict = Depends(require_admin), +): + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + cols: list[str] = [] + vals: list = [] + if "sort_order" in patch: + cols.append("sort_order = %s") + vals.append(patch["sort_order"]) + if "required" in patch: + cols.append("required = %s") + vals.append(patch["required"]) + if "ui_group" in patch: + cols.append("ui_group = %s") + vals.append(patch["ui_group"].strip() if patch["ui_group"] else None) + if not cols: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + vals.append(link_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id", + vals, + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True, "id": link_id} + + @router.delete("/training-category-parameters/{link_id}") def admin_delete_category_parameter( link_id: int, @@ -167,6 +214,41 @@ def admin_add_type_parameter( return {"id": new_id} +@router.put("/training-type-parameters/{link_id}") +def admin_update_type_parameter( + link_id: int, + body: TypeParameterUpdate, + session: dict = Depends(require_admin), +): + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + cols: list[str] = [] + vals: list = [] + if "sort_order" in patch: + cols.append("sort_order = %s") + vals.append(patch["sort_order"]) + if "required" in patch: + cols.append("required = %s") + vals.append(patch["required"]) + if "ui_group" in patch: + cols.append("ui_group = %s") + vals.append(patch["ui_group"].strip() if patch["ui_group"] else None) + if not cols: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + vals.append(link_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id", + vals, + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + conn.commit() + return {"ok": True, "id": link_id} + + @router.delete("/training-type-parameters/{link_id}") def admin_delete_type_parameter( link_id: int, diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx index 3fae2b4..f0e21cd 100644 --- a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx +++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react' import { Link } from 'react-router-dom' -import { Plus, Trash2, Save, RefreshCw } from 'lucide-react' +import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react' import { api } from '../utils/api' const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance'] @@ -18,6 +18,7 @@ const emptyParamForm = () => ({ }) export default function AdminActivityAttributeProfilesPage() { + const [tab, setTab] = useState('catalog') const [params, setParams] = useState([]) const [includeInactive, setIncludeInactive] = useState(false) const [catMeta, setCatMeta] = useState({}) @@ -28,6 +29,7 @@ export default function AdminActivityAttributeProfilesPage() { const [showParamForm, setShowParamForm] = useState(false) const [paramForm, setParamForm] = useState(emptyParamForm()) + const [editParam, setEditParam] = useState(null) const [selCategory, setSelCategory] = useState('cardio') const [catLinks, setCatLinks] = useState([]) @@ -37,6 +39,8 @@ export default function AdminActivityAttributeProfilesPage() { required: false, ui_group: '', }) + const [editingCatId, setEditingCatId] = useState(null) + const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' }) const [selTypeId, setSelTypeId] = useState('') const [typeLinks, setTypeLinks] = useState([]) @@ -46,6 +50,8 @@ export default function AdminActivityAttributeProfilesPage() { required: '', ui_group: '', }) + const [editingTypeId, setEditingTypeId] = useState(null) + const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' }) const showToast = (msg) => { setToast(msg) @@ -108,6 +114,11 @@ export default function AdminActivityAttributeProfilesPage() { const activeParams = params.filter((p) => p.is_active !== false) + const categoryKeys = + Object.keys(catMeta).length > 0 + ? Object.keys(catMeta).sort() + : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other'] + const saveNewParameter = async () => { setError(null) if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) { @@ -135,6 +146,28 @@ export default function AdminActivityAttributeProfilesPage() { } } + const saveEditedParameter = async () => { + if (!editParam) return + setError(null) + try { + await api.adminUpdateTrainingParameter(editParam.id, { + name_de: editParam.name_de.trim(), + name_en: editParam.name_en.trim(), + category: editParam.category, + data_type: editParam.data_type, + unit: editParam.unit?.trim() || null, + source_field: editParam.source_field?.trim() || null, + is_active: !!editParam.is_active, + validation_rules: editParam.validation_rules || {}, + }) + showToast('Parameter gespeichert') + setEditParam(null) + await refreshCatalog() + } catch (e) { + setError(e.message || 'Update fehlgeschlagen') + } + } + const deactivateParameter = async (id) => { if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return try { @@ -168,6 +201,21 @@ export default function AdminActivityAttributeProfilesPage() { } } + const saveCatLink = async (linkId) => { + try { + await api.adminUpdateTrainingCategoryParameter(linkId, { + sort_order: Number(catDraft.sort_order) || 0, + required: !!catDraft.required, + ui_group: catDraft.ui_group.trim() || null, + }) + showToast('Kategorie-Zuordnung aktualisiert') + setEditingCatId(null) + await loadCatLinks() + } catch (e) { + setError(e.message || 'Update fehlgeschlagen') + } + } + const addTypeLink = async () => { const tid = Number(selTypeId) const pid = parseInt(typeAdd.training_parameter_id, 10) @@ -179,7 +227,8 @@ export default function AdminActivityAttributeProfilesPage() { training_type_id: tid, training_parameter_id: pid, sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order), - required: typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true, + required: + typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true, ui_group: typeAdd.ui_group.trim() || null, } try { @@ -192,10 +241,28 @@ export default function AdminActivityAttributeProfilesPage() { } } - const categoryKeys = - Object.keys(catMeta).length > 0 - ? Object.keys(catMeta).sort() - : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other'] + const saveTypeLink = async (linkId) => { + try { + const payload = {} + if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) { + payload.sort_order = Number(typeDraft.sort_order) + } else { + payload.sort_order = null + } + if (typeDraft.required === '' || typeDraft.required == null) { + payload.required = null + } else { + payload.required = typeDraft.required === true || typeDraft.required === 'true' + } + payload.ui_group = typeDraft.ui_group.trim() || null + await api.adminUpdateTrainingTypeParameter(linkId, payload) + showToast('Typ-Zuordnung aktualisiert') + setEditingTypeId(null) + await loadTypeLinks() + } catch (e) { + setError(e.message || 'Update fehlgeschlagen') + } + } if (loading && !params.length) { return ( @@ -213,11 +280,30 @@ export default function AdminActivityAttributeProfilesPage() {

Session-Metriken (EAV)

-

- Messgrößen-Katalog und Zuordnung zu Kategorie (Basis) bzw.{' '} - Trainingstyp (Zusatz/Override). Nutzer sehen die Felder beim Bearbeiten einer - Aktivität, wenn der Eintrag passend kategorisiert ist. -

+ +
+ Hinweise +
    +
  • + Daten: Offizielle Migrationen löschen keine Zeilen in activity_log. Leere + Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin – vor Prod immer{' '} + pg_dump. +
  • +
  • + Doppelzuordnung: Dieselbe Metrik darf eine Zeile pro Kategorie und eine{' '} + pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in Kategorie und{' '} + Trainingstyp vorkommt, überschreibt die Typ-Zeile sort/required/ui_group + (Merge in Layer 1) – kein Datenfehler. +
  • +
  • + Nach Migration 055 werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '} + activity_log-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert). +
  • +
+
{toast && (
)} -
-
- Parameter-Katalog - - - -
- - {showParamForm && ( -
+ {[ + ['catalog', 'Katalog'], + ['category', 'Kategorie'], + ['type', 'Trainingstyp'], + ].map(([id, label]) => ( + - -
-
- )} + {label} + + ))} +
-
- - - - - - - - - - - - {params.map((r) => ( - - - - - - - - - ))} - -
IDkeyDETypaktiv -
{r.id} - {r.key} - {r.name_de} - {r.data_type} · {r.category} - {r.is_active === false ? 'nein' : 'ja'} - {r.is_active !== false && ( + {tab === 'catalog' && ( +
+
+ Parameter-Katalog + + + +
+ + {showParamForm && ( +
+
+ + setParamForm((f) => ({ ...f, key: e.target.value }))} + /> +
+
+ + setParamForm((f) => ({ ...f, name_de: e.target.value }))} + /> + setParamForm((f) => ({ ...f, name_en: e.target.value }))} + /> +
+
+ + + +
+
+ + setParamForm((f) => ({ ...f, unit: e.target.value }))} + /> + setParamForm((f) => ({ ...f, source_field: e.target.value }))} + /> +
+
+ + +
+
+ )} + + {editParam && ( +
+
+ Bearbeiten: {editParam.key} +
+
+ + setEditParam((p) => ({ ...p, name_de: e.target.value }))} + /> + setEditParam((p) => ({ ...p, name_en: e.target.value }))} + /> +
+
+ + + +
+
+ + setEditParam((p) => ({ ...p, unit: e.target.value }))} + /> + setEditParam((p) => ({ ...p, source_field: e.target.value }))} + /> +
+ +
+ + +
+
+ )} + +
+ + + + + + + + + + + + {params.map((r) => ( + + + + + + + + + ))} + +
IDkeyDETypaktiv +
{r.id} + {r.key} + {r.name_de} + {r.data_type} · {r.category} + {r.is_active === false ? 'nein' : 'ja'} +
+ + {r.is_active !== false && ( + + )} +
+
+
+
+ )} + + {tab === 'category' && ( +
+
Zuordnung: Trainings-Kategorie
+
+ + +
+
+
+ + +
+
+ + setCatAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+ +
+ + setCatAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+ +
+
    + {catLinks.map((l) => ( +
  • + {editingCatId === l.id ? ( +
    + + {l.parameter_key} · {l.parameter_name_de} + +
    + + setCatDraft((d) => ({ ...d, sort_order: e.target.value }))} + /> +
    + + setCatDraft((d) => ({ ...d, ui_group: e.target.value }))} + /> + + +
    + ) : ( +
    + + {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order} + {l.required ? ' · Pflicht' : ''} + {l.ui_group ? ` · ${l.ui_group}` : ''} + +
    + - )} -
-
-
- -
-
Zuordnung: Trainings-Kategorie
-
- - +
-
-
- + )} + + {tab === 'type' && ( +
+
Zuordnung: Trainingstyp (Zusatz / Override)
+
+
-
- - setCatAdd((a) => ({ ...a, sort_order: e.target.value }))} - /> +
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))} + /> +
+
+ + +
+
+ + setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))} + /> +
+
- -
- - setCatAdd((a) => ({ ...a, ui_group: e.target.value }))} - /> -
- -
-
    - {catLinks.map((l) => ( -
  • - - {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order} - {l.required ? ' · Pflicht' : ''} - {l.ui_group ? ` · ${l.ui_group}` : ''} - - -
  • - ))} -
-
- -
-
Zuordnung: Trainingstyp (Zusatz / Override)
-
- - setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))} + /> +
+ + setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))} + /> + + +
+ ) : ( +
+ + {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '} + {l.required === null || l.required === undefined + ? '—' + : l.required + ? 'ja' + : 'nein'} + +
+ + +
+
+ )} + ))} - +
-
-
- - -
-
- - setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))} - /> -
-
- - -
-
- - setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))} - /> -
- -
-
    - {typeLinks.map((l) => ( -
  • - - {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '} - {l.required === null || l.required === undefined ? '—' : l.required ? 'ja' : 'nein'} - - -
  • - ))} -
-
+ )}
) } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index f09d55c..89cfb02 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -326,11 +326,15 @@ export const api = { `/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`, ), adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)), + adminUpdateTrainingCategoryParameter: (id, d) => + req(`/admin/training-category-parameters/${id}`, jput(d)), adminDeleteTrainingCategoryParameter: (id) => req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }), adminListTrainingTypeParameters: (trainingTypeId) => req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`), adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)), + adminUpdateTrainingTypeParameter: (id, d) => + req(`/admin/training-type-parameters/${id}`, jput(d)), adminDeleteTrainingTypeParameter: (id) => req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }), From db9952525a1813249b5062202eacf3c468f7c2a9 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 12:32:38 +0200 Subject: [PATCH 04/17] feat: Add endpoints for activity statistics and uncategorized activities - Implemented a new endpoint to retrieve activity statistics for the last 30 entries, including total calories and duration by activity type. - Added an endpoint to list activities without assigned training types, grouped by activity type. - Removed deprecated versions of the statistics and uncategorized activities endpoints for cleaner code. --- backend/routers/activity.py | 108 ++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 48 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 492ba24..348fef4 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -133,6 +133,66 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default return {"id":eid,"date":e.date} +@router.get("/stats") +def activity_stats(session: dict = Depends(require_auth)): + """Get activity statistics (last 30 entries).""" + pid = str(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + profile = r2d(cur.fetchone()) + quality_filter = get_quality_filter_sql(profile or {}) + cur.execute( + f""" + SELECT * FROM activity_log + WHERE profile_id=%s {quality_filter} + ORDER BY date DESC + LIMIT 30 + """, + (pid,), + ) + rows = [r2d(r) for r in cur.fetchall()] + if not rows: + return {"count": 0, "total_kcal": 0, "total_min": 0, "by_type": {}} + total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows) + total_min = sum(float(r.get("duration_min") or 0) for r in rows) + by_type = {} + for r in rows: + t = r["activity_type"] + by_type.setdefault(t, {"count": 0, "kcal": 0, "min": 0}) + by_type[t]["count"] += 1 + by_type[t]["kcal"] += float(r.get("kcal_active") or 0) + by_type[t]["min"] += float(r.get("duration_min") or 0) + return { + "count": len(rows), + "total_kcal": round(total_kcal), + "total_min": round(total_min), + "by_type": by_type, + } + + +@router.get("/uncategorized") +def list_uncategorized_activities( + session: dict = Depends(require_auth), +): + """Get activities without assigned training type, grouped by activity_type.""" + pid = str(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT activity_type, COUNT(*) as count, + MIN(date) as first_date, MAX(date) as last_date + FROM activity_log + WHERE profile_id=%s AND training_type_id IS NULL + GROUP BY activity_type + ORDER BY count DESC + """, + (pid,), + ) + return [r2d(r) for r in cur.fetchall()] + + @router.put("/{eid}") def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Update existing activity entry.""" @@ -228,37 +288,6 @@ def get_activity_session( return unit -@router.get("/stats") -def activity_stats(session: dict = Depends(require_auth)): - """Get activity statistics (last 30 entries).""" - pid = str(session["profile_id"]) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) - profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile or {}) - cur.execute( - f""" - SELECT * FROM activity_log - WHERE profile_id=%s {quality_filter} - ORDER BY date DESC - LIMIT 30 - """, - (pid,), - ) - rows = [r2d(r) for r in cur.fetchall()] - if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}} - total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows) - total_min=sum(float(r.get('duration_min') or 0) for r in rows) - by_type={} - for r in rows: - t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0}) - by_type[t]['count']+=1 - by_type[t]['kcal']+=float(r.get('kcal_active') or 0) - by_type[t]['min']+=float(r.get('duration_min') or 0) - return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type} - - def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None): """ Wie get_training_type_for_activity, aber mit bestehendem Cursor (z. B. Universal-CSV-Import). @@ -312,23 +341,6 @@ def get_training_type_for_activity(activity_type: str, profile_id: str = None): return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id) -@router.get("/uncategorized") -def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Get activities without assigned training type, grouped by activity_type.""" - pid = get_pid(x_profile_id) - with get_db() as conn: - cur = get_cursor(conn) - cur.execute(""" - SELECT activity_type, COUNT(*) as count, - MIN(date) as first_date, MAX(date) as last_date - FROM activity_log - WHERE profile_id=%s AND training_type_id IS NULL - GROUP BY activity_type - ORDER BY count DESC - """, (pid,)) - return [r2d(r) for r in cur.fetchall()] - - @router.post("/bulk-categorize") def bulk_categorize_activities( data: dict, From 3296dfca28a2b2f7e626ed8b0fd1eb648968398c Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 12:53:35 +0200 Subject: [PATCH 05/17] 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}`), From 766b64cd64c2c98e72cf9a68456d9ec86fad1fb5 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 12:59:47 +0200 Subject: [PATCH 06/17] feat: Expand ActivityEntry model and enhance activity log handling - Added new fields to the ActivityEntry model for improved tracking: hr_min, pace_min_per_km, cadence, avg_power, elevation_gain, temperature_celsius, humidity_percent, avg_hr_percent, and kcal_per_km. - Updated the create_activity function to accommodate the new fields in the activity log. - Modified session metrics handling to ensure accurate data retrieval and merging based on the updated schema. --- .../data_layer/activity_session_metrics.py | 36 +++++++++++++++++-- backend/models.py | 9 +++++ backend/routers/activity.py | 14 ++++++-- frontend/src/pages/ActivityPage.jsx | 15 ++++++-- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index e00fc8b..ed6f335 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -391,8 +391,8 @@ 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) + # Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde + # Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen. return fetch_activity_session_metrics(cur, activity_log_id) @@ -408,10 +408,40 @@ def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str cur, header.get("training_category"), header.get("training_type_id") ) metrics = fetch_activity_session_metrics(cur, activity_log_id) + by_key = {m["key"]: m for m in metrics} + merged_metrics: List[Dict[str, Any]] = list(metrics) + for s in schema: + k = s["key"] + if k in by_key: + continue + sf = s.get("source_field") + if not sf or (isinstance(sf, str) and not str(sf).strip()): + continue + col = str(sf).strip() + if col not in header: + continue + raw = header.get(col) + if raw is None: + continue + dt = s["data_type"] + try: + val = _coerce_raw_value_for_parameter(dt, raw) + except (TypeError, ValueError): + continue + merged_metrics.append( + { + "training_parameter_id": s["training_parameter_id"], + "key": k, + "data_type": dt, + "unit": s.get("unit"), + "value": val, + } + ) + merged_metrics.sort(key=lambda x: x["key"]) return { "header": header, "schema": schema, - "metrics": metrics, + "metrics": merged_metrics, } diff --git a/backend/models.py b/backend/models.py index 8be2d09..b0462ad 100644 --- a/backend/models.py +++ b/backend/models.py @@ -83,8 +83,17 @@ class ActivityEntry(BaseModel): kcal_resting: Optional[float] = None hr_avg: Optional[float] = None hr_max: Optional[float] = None + hr_min: Optional[int] = None # DB-Spalte hr_min (Parameter min_hr) distance_km: Optional[float] = None rpe: Optional[int] = None + pace_min_per_km: Optional[float] = None + cadence: Optional[int] = None + avg_power: Optional[int] = None + elevation_gain: Optional[int] = None + temperature_celsius: Optional[float] = None + humidity_percent: Optional[int] = None + avg_hr_percent: Optional[float] = None + kcal_per_km: Optional[float] = None source: Optional[str] = 'manual' notes: Optional[str] = None training_type_id: Optional[int] = None # v9d: Training type categorization diff --git a/backend/routers/activity.py b/backend/routers/activity.py index e8f7609..7d8eaea 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -102,9 +102,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default 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, + hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain, + temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_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)""", + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", ( eid, pid, @@ -117,7 +118,16 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default d["kcal_resting"], d["hr_avg"], d["hr_max"], + d.get("hr_min"), d["distance_km"], + d.get("pace_min_per_km"), + d.get("cadence"), + d.get("avg_power"), + d.get("elevation_gain"), + d.get("temperature_celsius"), + d.get("humidity_percent"), + d.get("avg_hr_percent"), + d.get("kcal_per_km"), d["rpe"], d["source"], d["notes"], diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 2ebfb7d..9b4b845 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, startTransition } from 'react' import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' @@ -26,7 +26,16 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ 'kcal_resting', 'hr_avg', 'hr_max', + 'hr_min', 'distance_km', + 'pace_min_per_km', + 'cadence', + 'avg_power', + 'elevation_gain', + 'temperature_celsius', + 'humidity_percent', + 'avg_hr_percent', + 'kcal_per_km', 'rpe', 'source', 'notes', @@ -412,7 +421,9 @@ export default function ActivityPage() { } setEditing(null) setSessionDetail(null) - await load() + startTransition(() => { + void load() + }) } catch (err) { setError(err.message || 'Speichern fehlgeschlagen') setTimeout(() => setError(null), 6000) From 1f51c32521964974a5f5b3177005d2a4974e55ce Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 14:11:01 +0200 Subject: [PATCH 07/17] feat: Enhance activity listing and statistics retrieval with pagination and quality filter options - Added pagination support to the activity listing endpoint with `limit` and `offset` parameters. - Introduced a `skip_quality_filter` option to allow retrieval of all entries without applying the quality filter. - Updated the frontend to implement dynamic loading of activity entries and statistics without the quality filter. - Improved user experience with a "Load More" button for fetching additional entries on the ActivityPage. --- backend/routers/activity.py | 41 ++++++++++++----- frontend/src/pages/ActivityPage.jsx | 71 ++++++++++++++++++++++++++--- frontend/src/utils/api.js | 18 ++++++-- 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 7d8eaea..d698c39 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -35,7 +35,12 @@ except Exception as e: @router.get("") def list_activity( limit: int = Query(200, ge=1, le=50_000), + offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"), days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"), + skip_quality_filter: bool = Query( + False, + description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.", + ), session: dict = Depends(require_auth), ): """Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*).""" @@ -44,10 +49,13 @@ def list_activity( with get_db() as conn: cur = get_cursor(conn) - # Issue #31: Apply global quality filter (profile from DB = saved level) - cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) - profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile or {}) + # Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter) + if skip_quality_filter: + quality_filter = "" + else: + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + profile = r2d(cur.fetchone()) + quality_filter = get_quality_filter_sql(profile or {}) if days is not None: cur.execute( @@ -57,9 +65,9 @@ def list_activity( {quality_filter} AND date >= (CURRENT_DATE - %s::integer) ORDER BY date DESC, start_time DESC - LIMIT %s + LIMIT %s OFFSET %s """, - (pid, days, limit), + (pid, days, limit, offset), ) else: cur.execute( @@ -68,9 +76,9 @@ def list_activity( WHERE profile_id=%s {quality_filter} ORDER BY date DESC, start_time DESC - LIMIT %s + LIMIT %s OFFSET %s """, - (pid, limit), + (pid, limit, offset), ) return [r2d(r) for r in cur.fetchall()] @@ -167,14 +175,23 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default @router.get("/stats") -def activity_stats(session: dict = Depends(require_auth)): +def activity_stats( + skip_quality_filter: bool = Query( + False, + description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).", + ), + session: dict = Depends(require_auth), +): """Get activity statistics (last 30 entries).""" pid = str(session["profile_id"]) with get_db() as conn: cur = get_cursor(conn) - cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) - profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile or {}) + if skip_quality_filter: + quality_filter = "" + else: + cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) + profile = r2d(cur.fetchone()) + quality_filter = get_quality_filter_sql(profile or {}) cur.execute( f""" SELECT * FROM activity_log diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 9b4b845..eeee7d6 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, startTransition } from 'react' +import { useState, useEffect, useRef, useCallback, startTransition } from 'react' import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react' import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts' import { api } from '../utils/api' @@ -9,6 +9,9 @@ import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') +/** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */ +const ACTIVITY_LIST_PAGE_SIZE = 40 + const ACTIVITY_TYPES = [ 'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang', 'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen', @@ -299,12 +302,48 @@ export default function ActivityPage() { const [metricDraft, setMetricDraft] = useState({}) const [sessionLoadError, setSessionLoadError] = useState(null) const [savingEdit, setSavingEdit] = useState(false) + const [listHasMore, setListHasMore] = useState(false) + const [listLoadingMore, setListLoadingMore] = useState(false) - const load = async () => { - const [e, s] = await Promise.all([api.listActivity(), api.activityStats()]) - setEntries(e); setStats(s) + const loadFirstPage = useCallback(async () => { + const n = ACTIVITY_LIST_PAGE_SIZE + const [e, s] = await Promise.all([ + api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }), + api.activityStats({ skipQualityFilter: true }), + ]) + setEntries(e) + setStats(s) + setListHasMore(e.length === n) + }, []) + + const entriesRef = useRef(entries) + useEffect(() => { + entriesRef.current = entries + }, [entries]) + + const loadMore = async () => { + if (listLoadingMore || !listHasMore) return + setListLoadingMore(true) + try { + const n = ACTIVITY_LIST_PAGE_SIZE + const offset = entriesRef.current.length + const more = await api.listActivity(n, undefined, { + skipQualityFilter: true, + offset, + }) + if (more.length === 0) { + setListHasMore(false) + return + } + setEntries((prev) => [...prev, ...more]) + setListHasMore(more.length === n) + } finally { + setListLoadingMore(false) + } } + const load = loadFirstPage + const loadUsage = () => { api.getFeatureUsage().then(features => { const activityFeature = features.find(f => f.feature_id === 'activity_entries') @@ -312,11 +351,11 @@ export default function ActivityPage() { }).catch(err => console.error('Failed to load usage:', err)) } - useEffect(()=>{ - load() + useEffect(() => { + void loadFirstPage() loadUsage() api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) - },[]) + }, [loadFirstPage]) useEffect(() => { if (!editing?.id) { @@ -536,6 +575,11 @@ export default function ActivityPage() { {tab==='list' && (
+

+ Hier sind alle Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei + abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es werden jeweils bis zu{' '} + {ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen. +

{entries.length===0 && (

Keine Trainings

@@ -678,6 +722,19 @@ export default function ActivityPage() {
) })} + {listHasMore && ( +
+ +
+ )}
)}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 9233599..199ca0d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -138,16 +138,28 @@ export const api = { deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}), // Activity - /** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert */ - listActivity: (limit=200, days)=> { + /** + * @param {number} [limit=200] + * @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert + * @param {{ offset?: number, skipQualityFilter?: boolean }} [opts] + */ + listActivity: (limit=200, days, opts={})=> { const q = new URLSearchParams({ limit: String(limit) }) if (days != null && days !== '') q.set('days', String(days)) + if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset)) + if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') return req(`/activity?${q}`) }, createActivity: (d) => req('/activity',json(d)), updateActivity: (id,d) => req(`/activity/${id}`,jput(d)), deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}), - activityStats: () => req('/activity/stats'), + /** @param {{ skipQualityFilter?: boolean }} [opts] */ + activityStats: (opts={}) => { + const q = new URLSearchParams() + if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') + const qs = q.toString() + return req(`/activity/stats${qs ? `?${qs}` : ''}`) + }, listUncategorizedActivities: () => req('/activity/uncategorized'), bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)), importActivityCsv: async(file)=>{ From 9fdb02ff8bb61ad4dc91058499ed579353149e1b Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 14:25:17 +0200 Subject: [PATCH 08/17] feat: Refactor activity session metrics handling and enhance activity listing - Updated the `replace_activity_session_metrics` function to improve validation logic and error handling for required fields. - Enhanced the activity listing query to order results by date, start time, and ID, ensuring consistent output. - Modified the frontend to handle null values in metrics payload and improved the display of activity statistics, including total entries in profile and sample size. --- .../data_layer/activity_session_metrics.py | 21 +++++++++---- backend/routers/activity.py | 22 ++++++++++--- frontend/src/pages/ActivityPage.jsx | 31 +++++++++++++++---- 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index ed6f335..a2459d2 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -356,7 +356,7 @@ def replace_activity_session_metrics( cur, row.get("training_category"), row.get("training_type_id") ) by_key = {s["key"]: s for s in schema} - payload_keys = set() + payload_by_key: Dict[str, Dict[str, Any]] = {} for item in metrics: raw_k = item.get("parameter_key") if raw_k is None or not str(raw_k).strip(): @@ -364,11 +364,15 @@ def replace_activity_session_metrics( k = str(raw_k).strip() if k not in by_key: raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}") - payload_keys.add(k) + payload_by_key[k] = item for s in schema: - if s["required"] and s["key"] not in payload_keys: - raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {s['key']}") + if not s["required"]: + continue + itk = s["key"] + hit = payload_by_key.get(itk) + if hit is None or hit.get("value") is None: + raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}") cur.execute( "DELETE FROM activity_session_metrics WHERE activity_log_id = %s", @@ -378,9 +382,14 @@ def replace_activity_session_metrics( for item in metrics: k = str(item["parameter_key"]).strip() spec = by_key[k] + val = item.get("value") + if val is None: + if spec["required"]: + raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}") + continue rules = _validation_rules_dict(spec["validation_rules"]) - _validate_single_value(spec["data_type"], item.get("value"), rules) - vn, vi, vt, vb = _row_value_tuple(spec["data_type"], item["value"]) + _validate_single_value(spec["data_type"], val, rules) + vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val) cur.execute( """ INSERT INTO activity_session_metrics ( diff --git a/backend/routers/activity.py b/backend/routers/activity.py index d698c39..8237f1f 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -64,7 +64,7 @@ def list_activity( WHERE profile_id=%s {quality_filter} AND date >= (CURRENT_DATE - %s::integer) - ORDER BY date DESC, start_time DESC + ORDER BY date DESC, start_time DESC NULLS LAST, id DESC LIMIT %s OFFSET %s """, (pid, days, limit, offset), @@ -75,7 +75,7 @@ def list_activity( SELECT * FROM activity_log WHERE profile_id=%s {quality_filter} - ORDER BY date DESC, start_time DESC + ORDER BY date DESC, start_time DESC NULLS LAST, id DESC LIMIT %s OFFSET %s """, (pid, limit, offset), @@ -192,18 +192,30 @@ def activity_stats( cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) profile = r2d(cur.fetchone()) quality_filter = get_quality_filter_sql(profile or {}) + cur.execute( + f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}", + (pid,), + ) + total_in_profile = int(cur.fetchone()["c"]) cur.execute( f""" SELECT * FROM activity_log WHERE profile_id=%s {quality_filter} - ORDER BY date DESC + ORDER BY date DESC, start_time DESC NULLS LAST, id DESC LIMIT 30 """, (pid,), ) rows = [r2d(r) for r in cur.fetchall()] if not rows: - return {"count": 0, "total_kcal": 0, "total_min": 0, "by_type": {}} + return { + "count": 0, + "sample_size": 0, + "total_in_profile": total_in_profile, + "total_kcal": 0, + "total_min": 0, + "by_type": {}, + } total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows) total_min = sum(float(r.get("duration_min") or 0) for r in rows) by_type = {} @@ -215,6 +227,8 @@ def activity_stats( by_type[t]["min"] += float(r.get("duration_min") or 0) return { "count": len(rows), + "sample_size": len(rows), + "total_in_profile": total_in_profile, "total_kcal": round(total_kcal), "total_min": round(total_min), "by_type": by_type, diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index eeee7d6..ceb9f28 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -66,6 +66,7 @@ function buildMetricsPayload(schema, draft) { if (s.data_type === 'boolean') { if (raw === '' || raw === null || raw === undefined) { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) + out.push({ parameter_key: s.key, value: null }) continue } out.push({ parameter_key: s.key, value: !!raw }) @@ -73,6 +74,7 @@ function buildMetricsPayload(schema, draft) { } if (raw === '' || raw === null || raw === undefined) { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) + out.push({ parameter_key: s.key, value: null }) continue } let v = raw @@ -335,7 +337,14 @@ export default function ActivityPage() { setListHasMore(false) return } - setEntries((prev) => [...prev, ...more]) + const prev = entriesRef.current + const seen = new Set(prev.map((r) => r.id)) + const add = more.filter((r) => r.id && !seen.has(r.id)) + if (add.length === 0) { + setListHasMore(false) + return + } + setEntries((p) => [...p, ...add]) setListHasMore(more.length === n) } finally { setListLoadingMore(false) @@ -433,7 +442,10 @@ export default function ActivityPage() { 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 + if (raw === '' || raw === null || raw === undefined) { + payload[col] = null + continue + } let v = raw if (s.data_type === 'integer') { v = parseInt(String(raw), 10) @@ -506,12 +518,19 @@ export default function ActivityPage() {
{/* Übersicht */} - {stats && stats.count>0 && ( + {stats && (stats.total_in_profile > 0 || stats.count > 0) && (
+
+ {stats.total_in_profile ?? '–'} Einträge im Profil (gleicher Filter wie diese Seite). Die Summen + Kcal/Stunden beziehen sich auf die neuesten {stats.sample_size ?? stats.count} Einträge (max. + 30). +
- {[['Trainings',stats.count,'var(--text1)'], - ['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'], - ['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>( + {[ + ['Neueste (max. 30)', stats.count, 'var(--text1)'], + ['Kcal (darin)', Math.round(stats.total_kcal), '#EF9F27'], + ['Stunden (darin)', Math.round(stats.total_min / 60 * 10) / 10, '#378ADD'], + ].map(([l, v, c]) => (
{v}
{l}
From f718785145a4f73b8a6f3b605e88be62db97d2c0 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 14:34:10 +0200 Subject: [PATCH 09/17] feat: Add monthly activity fetching and improve activity listing - Introduced a new query parameter for the activity listing endpoint to fetch entries by calendar month (format: YYYY-MM), excluding days and offset. - Implemented backend validation for the month parameter to ensure correct format and range. - Enhanced the frontend to support month selection, allowing users to load activities for specific months and dynamically update the displayed entries. - Improved the user interface to show the selected month and the range of loaded months, enhancing user experience. --- backend/routers/activity.py | 39 +++++++ frontend/src/pages/ActivityPage.jsx | 157 ++++++++++++++++++++-------- frontend/src/utils/api.js | 3 +- 3 files changed, 156 insertions(+), 43 deletions(-) diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 8237f1f..3296012 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -7,6 +7,9 @@ import csv import io import uuid import logging +import re +import calendar +from datetime import date from typing import Optional from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query @@ -22,6 +25,19 @@ from data_layer.activity_session_metrics import sync_column_backed_session_metri router = APIRouter(prefix="/api/activity", tags=["activity"]) logger = logging.getLogger(__name__) +_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$") + + +def _month_date_bounds(ym: str) -> tuple[date, date]: + m = _MONTH_RE.match((ym or "").strip()) + if not m: + raise HTTPException(status_code=400, detail="month muss YYYY-MM sein") + y, mo = int(m.group(1)), int(m.group(2)) + if mo < 1 or mo > 12: + raise HTTPException(status_code=400, detail="Ungültiger Monat") + last = calendar.monthrange(y, mo)[1] + return date(y, mo, 1), date(y, mo, last) + # Evaluation import with error handling (Phase 1.2) try: from evaluation_helper import evaluate_and_save_activity @@ -37,6 +53,10 @@ def list_activity( limit: int = Query(200, ge=1, le=50_000), offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"), days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"), + month: Optional[str] = Query( + None, + description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)', + ), skip_quality_filter: bool = Query( False, description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.", @@ -57,6 +77,25 @@ def list_activity( profile = r2d(cur.fetchone()) quality_filter = get_quality_filter_sql(profile or {}) + if month: + if days is not None: + raise HTTPException(status_code=400, detail="month und days schließen sich aus") + if offset != 0: + raise HTTPException(status_code=400, detail="month und offset schließen sich aus") + d0, d1 = _month_date_bounds(month) + cur.execute( + f""" + SELECT * FROM activity_log + WHERE profile_id=%s + {quality_filter} + AND date >= %s AND date <= %s + ORDER BY date DESC, start_time DESC NULLS LAST, id DESC + LIMIT %s + """, + (pid, d0, d1, limit), + ) + return [r2d(r) for r in cur.fetchall()] + if days is not None: cur.execute( f""" diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index ceb9f28..58bbc99 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -9,8 +9,34 @@ import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') -/** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */ -const ACTIVITY_LIST_PAGE_SIZE = 40 +/** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */ +const ACTIVITY_MONTH_FETCH_LIMIT = 25_000 + +function ymdMonth(d = dayjs()) { + return d.format('YYYY-MM') +} + +function prevMonthYm(ym) { + return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM') +} + +function compareActivities(a, b) { + const da = a.date || '' + const db = b.date || '' + if (da !== db) return db.localeCompare(da) + const sa = String(a.start_time || '') + const sb = String(b.start_time || '') + if (sa !== sb) return sb.localeCompare(sa) + return String(b.id || '').localeCompare(String(a.id || '')) +} + +function dedupeActivitiesById(rows) { + const m = new Map() + for (const r of rows) { + if (r?.id) m.set(r.id, r) + } + return [...m.values()].sort(compareActivities) +} const ACTIVITY_TYPES = [ 'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang', @@ -304,54 +330,69 @@ export default function ActivityPage() { const [metricDraft, setMetricDraft] = useState({}) const [sessionLoadError, setSessionLoadError] = useState(null) const [savingEdit, setSavingEdit] = useState(false) - const [listHasMore, setListHasMore] = useState(false) const [listLoadingMore, setListLoadingMore] = useState(false) + const [selectedMonth, setSelectedMonth] = useState(() => ymdMonth()) + const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()]) + const monthsIncludedRef = useRef(monthsIncluded) - const loadFirstPage = useCallback(async () => { - const n = ACTIVITY_LIST_PAGE_SIZE - const [e, s] = await Promise.all([ - api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }), - api.activityStats({ skipQualityFilter: true }), - ]) - setEntries(e) + useEffect(() => { + monthsIncludedRef.current = monthsIncluded + }, [monthsIncluded]) + + const fetchMonthsChain = useCallback(async (chain) => { + const lists = await Promise.all( + chain.map((ym) => + api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { + skipQualityFilter: true, + month: ym, + }) + ) + ) + const merged = dedupeActivitiesById(lists.flat()) + const s = await api.activityStats({ skipQualityFilter: true }) + setEntries(merged) setStats(s) - setListHasMore(e.length === n) }, []) - const entriesRef = useRef(entries) - useEffect(() => { - entriesRef.current = entries - }, [entries]) + const load = useCallback(async () => { + await fetchMonthsChain(monthsIncludedRef.current) + }, [fetchMonthsChain]) - const loadMore = async () => { - if (listLoadingMore || !listHasMore) return + const onMonthPickerChange = (e) => { + const ym = e.target.value + if (!ym) return + setSelectedMonth(ym) + const chain = [ym] + monthsIncludedRef.current = chain + setMonthsIncluded(chain) + void fetchMonthsChain(chain) + } + + const loadPreviousMonth = async () => { + const chain = monthsIncludedRef.current + if (chain.length === 0) return + const oldest = chain[chain.length - 1] + const prev = prevMonthYm(oldest) + if (chain.includes(prev)) return + if (prev < '2000-01') return setListLoadingMore(true) try { - const n = ACTIVITY_LIST_PAGE_SIZE - const offset = entriesRef.current.length - const more = await api.listActivity(n, undefined, { + const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { skipQualityFilter: true, - offset, + month: prev, }) - if (more.length === 0) { - setListHasMore(false) - return - } - const prev = entriesRef.current - const seen = new Set(prev.map((r) => r.id)) - const add = more.filter((r) => r.id && !seen.has(r.id)) - if (add.length === 0) { - setListHasMore(false) - return - } - setEntries((p) => [...p, ...add]) - setListHasMore(more.length === n) + const newChain = [...chain, prev] + monthsIncludedRef.current = newChain + setMonthsIncluded(newChain) + setEntries((cur) => dedupeActivitiesById([...cur, ...more])) } finally { setListLoadingMore(false) } } - const load = loadFirstPage + const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth + const nextOlderYm = prevMonthYm(oldestLoadedYm) + const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm) const loadUsage = () => { api.getFeatureUsage().then(features => { @@ -361,10 +402,14 @@ export default function ActivityPage() { } useEffect(() => { - void loadFirstPage() + const ym = ymdMonth() + monthsIncludedRef.current = [ym] + setMonthsIncluded([ym]) + setSelectedMonth(ym) + void fetchMonthsChain([ym]) loadUsage() api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) - }, [loadFirstPage]) + }, [fetchMonthsChain]) useEffect(() => { if (!editing?.id) { @@ -594,10 +639,36 @@ export default function ActivityPage() { {tab==='list' && (
+
+ + {monthsIncluded.length > 1 && ( + + Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '} + {dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')} + + )} +

Hier sind alle Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei - abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es werden jeweils bis zu{' '} - {ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen. + abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es wird jeweils ein + kompletter Kalendermonat geladen; „Vorheriger Monat“ hängt den nächstälteren Monat an (ohne OFFSET-Pagination).

{entries.length===0 && (
@@ -741,16 +812,18 @@ export default function ActivityPage() {
) })} - {listHasMore && ( + {canLoadOlder && (
)} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 199ca0d..20b8d4a 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -141,11 +141,12 @@ export const api = { /** * @param {number} [limit=200] * @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert - * @param {{ offset?: number, skipQualityFilter?: boolean }} [opts] + * @param {{ offset?: number, skipQualityFilter?: boolean, month?: string }} [opts] month = YYYY-MM (schließt days/offset aus) */ listActivity: (limit=200, days, opts={})=> { const q = new URLSearchParams({ limit: String(limit) }) if (days != null && days !== '') q.set('days', String(days)) + if (opts.month) q.set('month', String(opts.month)) if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset)) if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') return req(`/activity?${q}`) From c6e8371d5a4a18d6a58bad674aa04e9c12f67bbf Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 14 Apr 2026 16:19:34 +0200 Subject: [PATCH 10/17] feat: Implement session deduplication in activity listing - Added a new query parameter `collapseDuplicateSessions` to the activity listing endpoint to enable deduplication of sessions based on date, type, start time, duration, and calories. - Enhanced backend logic to handle deduplication and return the most recent entry for duplicate sessions. - Updated frontend to support the new deduplication feature, improving the clarity of displayed activity data. - Modified API utility to include the new parameter in requests for activity data. --- backend/routers/activity.py | 175 ++++++++++++++++++++++++---- frontend/src/pages/ActivityPage.jsx | 2 + frontend/src/utils/api.js | 3 +- scripts/backup/mitai_pg_dump.sh | 35 ++++++ 4 files changed, 189 insertions(+), 26 deletions(-) create mode 100644 scripts/backup/mitai_pg_dump.sh diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 3296012..46f7991 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -9,9 +9,10 @@ import uuid import logging import re import calendar -from datetime import date +from datetime import date, time as dt_time from typing import Optional +from dateutil import parser as du_parser from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query from db import get_db, get_cursor, r2d @@ -38,6 +39,43 @@ def _month_date_bounds(ym: str) -> tuple[date, date]: last = calendar.monthrange(y, mo)[1] return date(y, mo, 1), date(y, mo, last) + +def _normalize_apple_health_start(start_raw: str) -> tuple[str, Optional[dt_time]]: + """ISO/Apple-Export Start → (YYYY-MM-DD, TIME ohne μs) für stabile Dedupe + INSERT.""" + s = (start_raw or "").strip() + if not s: + return "", None + try: + parsed = du_parser.parse(s, dayfirst=False) + t = parsed.time().replace(microsecond=0) + return parsed.date().isoformat(), t + except (ValueError, TypeError, OverflowError): + if len(s) >= 10: + return s[:10], None + return "", None + + +_ACTIVITY_DEDUP_WINDOW = """ + PARTITION BY al.profile_id, al.date, + COALESCE(al.activity_type, ''), + COALESCE(al.start_time::text, ''), + COALESCE(ROUND(al.duration_min::numeric, 1), '-999999'::numeric), + COALESCE(ROUND(al.kcal_active::numeric, 1), '-999999'::numeric) + ORDER BY al.created DESC NULLS LAST, al.id DESC +""" + + +def _activity_rows_after_list_query(cur): + rows = [] + for r in cur.fetchall(): + d = r2d(r) + if not d: + continue + d.pop("_dup_rn", None) + rows.append(d) + return rows + + # Evaluation import with error handling (Phase 1.2) try: from evaluation_helper import evaluate_and_save_activity @@ -61,6 +99,10 @@ def list_activity( False, description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.", ), + collapse_duplicate_sessions: bool = Query( + False, + description="True = Sessions mit gleichem Datum/Typ/Startzeit/Dauer/Kcal falten (neueste Zeile behalten).", + ), session: dict = Depends(require_auth), ): """Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*).""" @@ -72,10 +114,12 @@ def list_activity( # Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter) if skip_quality_filter: quality_filter = "" + quality_filter_al = "" else: cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile or {}) + quality_filter = get_quality_filter_sql(profile or {}, "") + quality_filter_al = get_quality_filter_sql(profile or {}, "al.") if month: if days is not None: @@ -83,6 +127,25 @@ def list_activity( if offset != 0: raise HTTPException(status_code=400, detail="month und offset schließen sich aus") d0, d1 = _month_date_bounds(month) + if collapse_duplicate_sessions: + cur.execute( + f""" + SELECT d.* FROM ( + SELECT al.*, ROW_NUMBER() OVER ( + {_ACTIVITY_DEDUP_WINDOW} + ) AS _dup_rn + FROM activity_log al + WHERE al.profile_id = %s + {quality_filter_al} + AND al.date >= %s AND al.date <= %s + ) d + WHERE d._dup_rn = 1 + ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC + LIMIT %s + """, + (pid, d0, d1, limit), + ) + return _activity_rows_after_list_query(cur) cur.execute( f""" SELECT * FROM activity_log @@ -97,6 +160,25 @@ def list_activity( return [r2d(r) for r in cur.fetchall()] if days is not None: + if collapse_duplicate_sessions: + cur.execute( + f""" + SELECT d.* FROM ( + SELECT al.*, ROW_NUMBER() OVER ( + {_ACTIVITY_DEDUP_WINDOW} + ) AS _dup_rn + FROM activity_log al + WHERE al.profile_id = %s + {quality_filter_al} + AND al.date >= (CURRENT_DATE - %s::integer) + ) d + WHERE d._dup_rn = 1 + ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC + LIMIT %s OFFSET %s + """, + (pid, days, limit, offset), + ) + return _activity_rows_after_list_query(cur) cur.execute( f""" SELECT * FROM activity_log @@ -109,6 +191,24 @@ def list_activity( (pid, days, limit, offset), ) else: + if collapse_duplicate_sessions: + cur.execute( + f""" + SELECT d.* FROM ( + SELECT al.*, ROW_NUMBER() OVER ( + {_ACTIVITY_DEDUP_WINDOW} + ) AS _dup_rn + FROM activity_log al + WHERE al.profile_id = %s + {quality_filter_al} + ) d + WHERE d._dup_rn = 1 + ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC + LIMIT %s OFFSET %s + """, + (pid, limit, offset), + ) + return _activity_rows_after_list_query(cur) cur.execute( f""" SELECT * FROM activity_log @@ -230,22 +330,40 @@ def activity_stats( else: cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) profile = r2d(cur.fetchone()) - quality_filter = get_quality_filter_sql(profile or {}) + quality_filter = get_quality_filter_sql(profile or {}, "") cur.execute( f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}", (pid,), ) total_in_profile = int(cur.fetchone()["c"]) - cur.execute( - f""" - SELECT * FROM activity_log - WHERE profile_id=%s {quality_filter} - ORDER BY date DESC, start_time DESC NULLS LAST, id DESC - LIMIT 30 - """, - (pid,), - ) - rows = [r2d(r) for r in cur.fetchall()] + if skip_quality_filter: + cur.execute( + f""" + SELECT d.* FROM ( + SELECT al.*, ROW_NUMBER() OVER ( + {_ACTIVITY_DEDUP_WINDOW} + ) AS _dup_rn + FROM activity_log al + WHERE al.profile_id = %s + ) d + WHERE d._dup_rn = 1 + ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC + LIMIT 30 + """, + (pid,), + ) + rows = _activity_rows_after_list_query(cur) + else: + cur.execute( + f""" + SELECT * FROM activity_log + WHERE profile_id=%s {quality_filter} + ORDER BY date DESC, start_time DESC NULLS LAST, id DESC + LIMIT 30 + """, + (pid,), + ) + rows = [r2d(r) for r in cur.fetchall()] if not rows: return { "count": 0, @@ -543,9 +661,11 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional for row in reader: wtype = row.get('Workout Type','').strip() start = row.get('Start','').strip() - if not wtype or not start: continue - try: date = start[:10] - except: continue + if not wtype or not start: + continue + workout_date, workout_start_t = _normalize_apple_health_start(start) + if not workout_date: + continue dur = row.get('Duration','').strip() duration_min = None if dur: @@ -563,11 +683,15 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid) try: - # Check if entry already exists (duplicate detection by date + start_time) - cur.execute(""" + # Duplicate detection: normiertes Datum + TIME (Apple-Export kann Start in verschiedenen Formaten liefern) + cur.execute( + """ SELECT id FROM activity_log - WHERE profile_id = %s AND date = %s AND start_time = %s - """, (pid, date, start)) + WHERE profile_id = %s AND date = %s::date + AND start_time IS NOT DISTINCT FROM %s::time + """, + (pid, workout_date, workout_start_t), + ) existing = cur.fetchone() if existing: @@ -575,7 +699,8 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional existing_id = existing['id'] cur.execute(""" UPDATE activity_log - SET end_time = %s, + SET start_time = %s, + end_time = %s, activity_type = %s, duration_min = %s, kcal_active = %s, @@ -588,7 +713,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional training_subcategory = %s WHERE id = %s """, ( - row.get('End',''), wtype, duration_min, + workout_start_t, row.get('End',''), wtype, duration_min, kj(row.get('Aktive Energie (kJ)','')), kj(row.get('Ruheeinträge (kJ)','')), tf(row.get('Durchschn. Herzfrequenz (count/min)','')), @@ -606,7 +731,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional activity_dict = { "id": existing_id, "profile_id": pid, - "date": date, + "date": workout_date, "training_type_id": training_type_id, "duration_min": duration_min, "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), @@ -630,7 +755,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional (id,profile_id,date,start_time,end_time,activity_type,duration_min,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,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", - (new_id,pid,date,start,row.get('End',''),wtype,duration_min, + (new_id,pid,workout_date,workout_start_t,row.get('End',''),wtype,duration_min, kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), tf(row.get('Durchschn. Herzfrequenz (count/min)','')), tf(row.get('Max. Herzfrequenz (count/min)','')), @@ -645,7 +770,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional activity_dict = { "id": new_id, "profile_id": pid, - "date": date, + "date": workout_date, "training_type_id": training_type_id, "duration_min": duration_min, "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 58bbc99..a53a58f 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -344,6 +344,7 @@ export default function ActivityPage() { chain.map((ym) => api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { skipQualityFilter: true, + collapseDuplicateSessions: true, month: ym, }) ) @@ -379,6 +380,7 @@ export default function ActivityPage() { try { const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, { skipQualityFilter: true, + collapseDuplicateSessions: true, month: prev, }) const newChain = [...chain, prev] diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 20b8d4a..68d0f89 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -141,13 +141,14 @@ export const api = { /** * @param {number} [limit=200] * @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert - * @param {{ offset?: number, skipQualityFilter?: boolean, month?: string }} [opts] month = YYYY-MM (schließt days/offset aus) + * @param {{ offset?: number, skipQualityFilter?: boolean, month?: string, collapseDuplicateSessions?: boolean }} [opts] month = YYYY-MM (schließt days/offset aus) */ listActivity: (limit=200, days, opts={})=> { const q = new URLSearchParams({ limit: String(limit) }) if (days != null && days !== '') q.set('days', String(days)) if (opts.month) q.set('month', String(opts.month)) if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset)) + if (opts.collapseDuplicateSessions) q.set('collapse_duplicate_sessions', 'true') if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') return req(`/activity?${q}`) }, diff --git a/scripts/backup/mitai_pg_dump.sh b/scripts/backup/mitai_pg_dump.sh new file mode 100644 index 0000000..b157b78 --- /dev/null +++ b/scripts/backup/mitai_pg_dump.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Volles PostgreSQL-Backup im Custom-Format (pg_restore-kompatibel). +# Auf dem Host ausführen, wo der Postgres-Container läuft (z. B. Raspberry Pi mit docker compose). +# +# BACKUP_DIR=/path/to/safe/storage ./scripts/backup/mitai_pg_dump.sh +# +# Variablen (optional): POSTGRES_CONTAINER, POSTGRES_DB, POSTGRES_USER, BACKUP_DIR + +set -euo pipefail + +CONTAINER="${POSTGRES_CONTAINER:-mitai-db-prod}" +DB="${POSTGRES_DB:-mitai_prod}" +USER="${POSTGRES_USER:-mitai_prod}" +OUT_DIR="${BACKUP_DIR:-.}" +STAMP="$(date +%Y%m%d_%H%M%S)" +mkdir -p "${OUT_DIR}" +OUT="${OUT_DIR}/${DB}_${STAMP}.dump" + +if ! docker inspect "$CONTAINER" &>/dev/null; then + echo "Container nicht gefunden: $CONTAINER" >&2 + exit 1 +fi + +docker exec "$CONTAINER" pg_dump -U "$USER" -Fc --no-owner --no-acl "$DB" > "$OUT" +ls -la "$OUT" +echo "OK: $OUT (zum Zurückspielen: siehe Kommentar unten in diesem Skript)" + +# ── Restore (nur bei Notfall; Backend vorher stoppen, sonst offene Verbindungen) ── +# docker compose stop backend +# docker cp "$OUT" "$CONTAINER:/tmp/restore.dump" +# docker exec "$CONTAINER" pg_restore -U "$USER" -d "$DB" --clean --if-exists --no-owner --no-acl /tmp/restore.dump +# docker compose start backend +# +# Hinweis: --clean entfernt Objekte vor dem Wiederherstellen; kurze Unterbrechung der DB. +# Für „nur lesen“ Backup reicht die .dump-Datei auf externem Medium zu kopieren. From 934b9153574f777a03e70c303d86e36a8433b46d Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 07:25:39 +0200 Subject: [PATCH 11/17] First Version EAV Importer. feat: Enhance activity detail retrieval with EAV metrics and refactor activity import logic - Updated the `get_activity_detail` function to include session metrics in the activity detail output, allowing for enriched data representation. - Refactored the activity import logic to streamline the process of inserting and updating activity records, utilizing new helper functions for better maintainability. - Improved the handling of duplicate activity entries by implementing a more robust identification mechanism. - Enhanced the metadata for activity detail registration to reflect the inclusion of EAV metrics and updated source tables. --- backend/csv_parser/executor.py | 180 ++++------ backend/data_layer/activity_metrics.py | 31 +- .../activity_persistence_orchestrator.py | 281 ++++++++++++++++ backend/data_layer/activity_time_normalize.py | 30 ++ .../activity_metrics.py | 41 ++- backend/placeholder_resolver.py | 15 +- backend/routers/activity.py | 318 ++++++------------ backend/tests/test_csv_import_executor.py | 15 +- 8 files changed, 550 insertions(+), 361 deletions(-) create mode 100644 backend/data_layer/activity_persistence_orchestrator.py create mode 100644 backend/data_layer/activity_time_normalize.py diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 60c5a80..10169a0 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -23,14 +23,6 @@ from csv_parser.type_converter import build_row_after_mapping logger = logging.getLogger(__name__) -try: - from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity - - _EVALUATION_AVAILABLE = True -except Exception: # pragma: no cover - _evaluate_and_save_activity = None - _EVALUATION_AVAILABLE = False - def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str): """Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock).""" @@ -814,6 +806,15 @@ def _import_activity( error_details: list, affected_ids: dict, ) -> dict[str, int]: + from data_layer.activity_time_normalize import normalize_activity_start + from data_layer.activity_persistence_orchestrator import ( + find_activity_duplicate_id, + insert_activity_csv_minimal, + new_activity_id, + run_activity_post_write_hooks_import, + update_activity_columns, + ) + rows_total = 0 inserted = 0 updated = 0 @@ -885,6 +886,7 @@ def _import_activity( wtype = str(activity_type).strip() iso = date_d.isoformat() + _, workout_start_t = normalize_activity_start(start_key) # Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende. cur.execute("SAVEPOINT csv_activity_row") @@ -892,113 +894,71 @@ def _import_activity( training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity( cur, wtype, profile_id ) - cur.execute( - """ - SELECT id FROM activity_log - WHERE profile_id = %s AND date = %s AND start_time = %s - """, - (profile_id, iso, start_key), - ) - existing = cur.fetchone() + existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t) - if existing: - eid = existing["id"] - cur.execute( - """ - UPDATE activity_log - SET end_time = %s, - activity_type = %s, - duration_min = %s, - kcal_active = %s, - kcal_resting = %s, - hr_avg = %s, - hr_max = %s, - distance_km = %s, - training_type_id = %s, - training_category = %s, - training_subcategory = %s, - source = 'csv' - WHERE id = %s - RETURNING id - """, - ( - end_str or None, - wtype, - duration_min, - kcal_a, - kcal_r, - hr_a, - hr_m, - dist, - training_type_id, - training_category, - training_subcategory, - eid, - ), - ) - row = cur.fetchone() - updated += 1 - if row and row.get("id"): - affected_ids["activity_log"].append(str(row["id"])) - aid = eid - else: - eid = str(uuid.uuid4()) - 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, - source, training_type_id, training_category, training_subcategory, created - ) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP) - RETURNING id - """, - ( - eid, - profile_id, - iso, - start_key, - end_str or None, - wtype, - duration_min, - kcal_a, - kcal_r, - hr_a, - hr_m, - dist, - training_type_id, - training_category, - training_subcategory, - ), - ) - row = cur.fetchone() - inserted += 1 - new_entries += 1 - if row and row.get("id"): - affected_ids["activity_log"].append(str(row["id"])) - aid = eid - - if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity: - try: - activity_dict = { - "id": aid, - "profile_id": profile_id, - "date": iso, - "training_type_id": training_type_id, + if existing_id: + update_activity_columns( + cur, + profile_id, + existing_id, + { + "start_time": workout_start_t, + "end_time": end_str or None, + "activity_type": wtype, "duration_min": duration_min, + "kcal_active": kcal_a, + "kcal_resting": kcal_r, "hr_avg": hr_a, "hr_max": hr_m, "distance_km": dist, - "kcal_active": kcal_a, - "kcal_resting": kcal_r, - "rpe": None, - "pace_min_per_km": None, - "cadence": None, - "elevation_gain": None, - } - _evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id) - except Exception as eval_err: - logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err) + "training_type_id": training_type_id, + "training_category": training_category, + "training_subcategory": training_subcategory, + "source": "csv", + }, + ) + updated += 1 + affected_ids["activity_log"].append(str(existing_id)) + aid = existing_id + else: + eid = new_activity_id() + insert_activity_csv_minimal( + cur, + profile_id, + eid, + date_iso=iso, + start_time=workout_start_t, + end_time=end_str or None, + activity_type=wtype, + duration_min=duration_min, + kcal_active=kcal_a, + kcal_resting=kcal_r, + hr_avg=hr_a, + hr_max=hr_m, + distance_km=dist, + training_type_id=training_type_id, + training_category=training_category, + training_subcategory=training_subcategory, + source="csv", + ) + inserted += 1 + new_entries += 1 + affected_ids["activity_log"].append(str(eid)) + aid = eid + + run_activity_post_write_hooks_import( + cur, + profile_id, + str(aid), + workout_date=iso, + training_type_id=training_type_id, + duration_min=duration_min, + hr_avg=hr_a, + hr_max=hr_m, + distance_km=dist, + kcal_active=kcal_a, + kcal_resting=kcal_r, + ) cur.execute("RELEASE SAVEPOINT csv_activity_row") except Exception as e: try: diff --git a/backend/data_layer/activity_metrics.py b/backend/data_layer/activity_metrics.py index a1afd7e..cfdd940 100644 --- a/backend/data_layer/activity_metrics.py +++ b/backend/data_layer/activity_metrics.py @@ -124,7 +124,8 @@ def get_activity_detail_data( "duration_min": int, "kcal_active": int, "hr_avg": int | None, - "training_category": str | None + "training_category": str | None, + "session_metrics": list | None, # EAV (enrich_sessions_with_metrics) }, ... ], @@ -143,6 +144,7 @@ def get_activity_detail_data( cur.execute( """SELECT + id, date, activity_type, duration_min, @@ -153,7 +155,7 @@ def get_activity_detail_data( WHERE profile_id=%s AND date >= %s ORDER BY date DESC LIMIT %s""", - (profile_id, cutoff, limit) + (profile_id, cutoff, limit), ) rows = cur.fetchall() @@ -162,19 +164,24 @@ def get_activity_detail_data( "activities": [], "total_count": 0, "confidence": "insufficient", - "days_analyzed": days + "days_analyzed": days, } activities = [] for row in rows: - activities.append({ - "date": row['date'], - "activity_type": row['activity_type'], - "duration_min": safe_int(row['duration_min']), - "kcal_active": safe_int(row['kcal_active']), - "hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None, - "training_category": row.get('training_category') - }) + activities.append( + { + "id": str(row["id"]), + "date": row["date"], + "activity_type": row["activity_type"], + "duration_min": safe_int(row["duration_min"]), + "kcal_active": safe_int(row["kcal_active"]), + "hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None, + "training_category": row.get("training_category"), + } + ) + + enrich_sessions_with_metrics(cur, activities) confidence = calculate_confidence(len(activities), days, "general") @@ -182,7 +189,7 @@ def get_activity_detail_data( "activities": activities, "total_count": len(activities), "confidence": confidence, - "days_analyzed": days + "days_analyzed": days, } diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py new file mode 100644 index 0000000..6e74242 --- /dev/null +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -0,0 +1,281 @@ +""" +Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, Spalten→EAV). + +Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen. + +Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog() +""" +from __future__ import annotations + +import logging +import uuid +from typing import Any, Dict, List, Optional + +from models import ActivityEntry + +from csv_parser.module_registry import get_module_definition +from data_layer.activity_session_metrics import sync_column_backed_session_metrics + +logger = logging.getLogger(__name__) + +try: + from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity + + _EVALUATION_AVAILABLE = True +except Exception: # pragma: no cover + _evaluate_and_save_activity = None + _EVALUATION_AVAILABLE = False + + +def find_activity_duplicate_id( + cur, + profile_id: str, + date_iso: str, + start_time: Optional[Any], +) -> Optional[str]: + cur.execute( + """ + SELECT id FROM activity_log + WHERE profile_id = %s AND date = %s::date + AND start_time IS NOT DISTINCT FROM %s::time + """, + (profile_id, date_iso, start_time), + ) + row = cur.fetchone() + return str(row["id"]) if row else None + + +def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None: + """INSERT activity_log aus ActivityEntry (manueller API-Pfad).""" + d = e.model_dump() + 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,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain, + temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_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,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", + ( + eid, + profile_id, + 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.get("hr_min"), + d["distance_km"], + d.get("pace_min_per_km"), + d.get("cadence"), + d.get("avg_power"), + d.get("elevation_gain"), + d.get("temperature_celsius"), + d.get("humidity_percent"), + d.get("avg_hr_percent"), + d.get("kcal_per_km"), + d["rpe"], + d["source"], + d["notes"], + d.get("training_type_id"), + d.get("training_category"), + d.get("training_subcategory"), + ), + ) + + +def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None: + """Volles UPDATE aus ActivityEntry (REST PUT).""" + d = e.model_dump() + cur.execute( + f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", + list(d.values()) + [eid, profile_id], + ) + + +def update_activity_columns( + cur, + profile_id: str, + eid: str, + updates: Dict[str, Any], +) -> None: + """Teil-UPDATE nur für übergebene Spalten (Importe).""" + if not updates: + return + cols = [f"{k} = %s" for k in updates] + vals = list(updates.values()) + [eid, profile_id] + cur.execute( + f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s", + vals, + ) + + +def insert_activity_csv_minimal( + cur, + profile_id: str, + eid: str, + *, + date_iso: str, + 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 minimale activity_log-Zeile (Universal-CSV).""" + 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, + source, 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,CURRENT_TIMESTAMP) + """, + ( + eid, + profile_id, + date_iso, + start_time, + end_time, + activity_type, + duration_min, + kcal_active, + kcal_resting, + hr_avg, + hr_max, + distance_km, + source, + training_type_id, + training_category, + training_subcategory, + ), + ) + + +def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None: + """Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten.""" + if _EVALUATION_AVAILABLE and _evaluate_and_save_activity: + 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 + """, + (eid,), + ) + activity_row = cur.fetchone() + if activity_row: + activity_dict = dict(activity_row) + training_type_id = activity_dict.get("training_type_id") + if training_type_id: + try: + _evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id) + except Exception as eval_error: + logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error) + sync_column_backed_session_metrics(cur, str(profile_id), str(eid)) + + +def run_activity_post_write_hooks_import( + cur, + profile_id: str, + eid: str, + *, + 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: + """Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict.""" + 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) + sync_column_backed_session_metrics(cur, str(profile_id), str(eid)) + + +def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]: + """ + Felder für konfigurierbare Import-Mappings. + + core_fields: module_registry „activity“ → activity_log. + training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session + werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen. + + profile_id: reserviert für künftige Profil-Filter. + """ + _ = profile_id + mod = get_module_definition("activity") or {} + fields = mod.get("fields") or {} + core_fields: List[Dict[str, Any]] = [] + for key, spec in fields.items(): + s = spec or {} + core_fields.append( + { + "key": key, + "target": "activity_log", + "column": key, + "data_type": s.get("type", "string"), + "required": bool(s.get("required")), + "unit": s.get("unit"), + "label_de": key, + } + ) + core_fields.sort(key=lambda x: x["key"]) + + cur.execute( + """ + SELECT id, key, name_de, name_en, category AS param_category, + data_type, unit, source_field + FROM training_parameters + WHERE is_active = true + ORDER BY key + """ + ) + parameters = [dict(r) for r in cur.fetchall()] + + return { + "core_fields": core_fields, + "training_parameters": parameters, + "notes": ( + "training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, " + "die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen." + ), + } + + +def new_activity_id() -> str: + return str(uuid.uuid4()) diff --git a/backend/data_layer/activity_time_normalize.py b/backend/data_layer/activity_time_normalize.py new file mode 100644 index 0000000..1b4fd8c --- /dev/null +++ b/backend/data_layer/activity_time_normalize.py @@ -0,0 +1,30 @@ +""" +Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe). + +Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil. +""" +from __future__ import annotations + +from datetime import time as dt_time +from typing import Optional + +from dateutil import parser as du_parser + + +def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]: + """ + Roh-String „Start“ aus Exporten → (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT. + + Leerer Input → ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen. + """ + s = (start_raw or "").strip() + if not s: + return "", None + try: + parsed = du_parser.parse(s, dayfirst=False) + t = parsed.time().replace(microsecond=0) + return parsed.date().isoformat(), t + except (ValueError, TypeError, OverflowError): + if len(s) >= 10: + return s[:10], None + return "", None diff --git a/backend/placeholder_registrations/activity_metrics.py b/backend/placeholder_registrations/activity_metrics.py index a61e526..5a01a20 100644 --- a/backend/placeholder_registrations/activity_metrics.py +++ b/backend/placeholder_registrations/activity_metrics.py @@ -127,16 +127,17 @@ def register_activity_group_1(): activity_detail_metadata = PlaceholderMetadata( key="activity_detail", category="Aktivität", - description="Detaillierte Liste der letzten 14 Tage Aktivität", + description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)", resolver_module="backend/placeholder_resolver.py", - resolver_function="_format_activity_detail", - data_layer_module=None, - data_layer_function=None, - source_tables=["activity_log", "training_types"], + resolver_function="get_activity_detail", + data_layer_module="backend/data_layer/activity_metrics.py", + data_layer_function="get_activity_detail_data", + source_tables=["activity_log", "activity_session_metrics", "training_parameters"], semantic_contract=( - "Liefert eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. " - "Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. " - "Sortiert chronologisch absteigend (neueste zuerst)." + "Liefert bis zu 50 Einheiten (neueste zuerst) der letzten 14 Tage über " + "get_activity_detail_data: activity_log-Spalten plus " + "enrich_sessions_with_metrics (activity_session_metrics / Profil-EAV). " + "Formatter hängt nicht-leere EAV-Werte als „| EAV: key=value; …“ an." ), business_meaning=( "Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen " @@ -147,7 +148,9 @@ def register_activity_group_1(): time_window="14d", output_type=OutputType.LIST, placeholder_type=PlaceholderType.RAW_DATA, - format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'", + format_hint=( + "Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken" + ), example_output=( "2026-03-28: Krafttraining (45 min)\\n" "2026-03-27: Laufen (30 min)\\n" @@ -163,19 +166,15 @@ def register_activity_group_1(): legacy_display="Keine Aktivitätsdaten" ), known_limitations=( - "OLD RESOLVER PATTERN: Keine Data Layer Funktion. " - "Formatierung direkt im Resolver. " - "CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) " - "werden gelistet. JOIN mit training_types für Typ-Namen." + "Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output " + "(Hard-Limit Resolver). Doppelte Spalten (z.B. duration_min in Kopf und EAV) können " + "in EAV wiederholt erscheinen — KI kann dominante Spalte nutzen." ), - layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)", - layer_2a_decision="Placeholder Resolver (formatting + SQL query)", - layer_2b_reuse_possible=False, - architecture_alignment=( - "NOT ALIGNED with Phase 0c Multi-Layer Architecture. " - "Should be refactored to use data_layer function." - ), - issue_53_alignment="NOT ALIGNED - no layer separation" + layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)", + layer_2a_decision="get_activity_detail (Formatierung)", + layer_2b_reuse_possible=True, + architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung", + issue_53_alignment="Layer 1" ) activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 61a5d43..7c97dab 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -419,14 +419,21 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str: # Format as readable list (max 20 entries to avoid token bloat) lines = [] - for activity in data['activities'][:20]: - hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else "" + for activity in data["activities"][:20]: + hr_str = f", HF={activity['hr_avg']}" if activity.get("hr_avg") else "" + eav_parts = [] + for m in activity.get("session_metrics") or []: + k, v = m.get("key"), m.get("value") + if k is None or v is None: + continue + eav_parts.append(f"{k}={v}") + eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else "" lines.append( f"{activity['date']}: {activity['activity_type']} " - f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})" + f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str}{eav_str})" ) - return '\n'.join(lines) + return "\n".join(lines) def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str: diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 46f7991..852fc8e 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -9,10 +9,9 @@ import uuid import logging import re import calendar -from datetime import date, time as dt_time +from datetime import date from typing import Optional -from dateutil import parser as du_parser from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query from db import get_db, get_cursor, r2d @@ -21,7 +20,18 @@ 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 +from data_layer.activity_persistence_orchestrator import ( + get_mappable_activity_field_catalog, + insert_activity_from_entry, + run_activity_post_write_hooks, + update_activity_from_entry, + find_activity_duplicate_id, + update_activity_columns, + insert_activity_csv_minimal, + run_activity_post_write_hooks_import, + new_activity_id, +) +from data_layer.activity_time_normalize import normalize_activity_start router = APIRouter(prefix="/api/activity", tags=["activity"]) logger = logging.getLogger(__name__) @@ -40,21 +50,6 @@ def _month_date_bounds(ym: str) -> tuple[date, date]: return date(y, mo, 1), date(y, mo, last) -def _normalize_apple_health_start(start_raw: str) -> tuple[str, Optional[dt_time]]: - """ISO/Apple-Export Start → (YYYY-MM-DD, TIME ohne μs) für stabile Dedupe + INSERT.""" - s = (start_raw or "").strip() - if not s: - return "", None - try: - parsed = du_parser.parse(s, dayfirst=False) - t = parsed.time().replace(microsecond=0) - return parsed.date().isoformat(), t - except (ValueError, TypeError, OverflowError): - if len(s) >= 10: - return s[:10], None - return "", None - - _ACTIVITY_DEDUP_WINDOW = """ PARTITION BY al.profile_id, al.date, COALESCE(al.activity_type, ''), @@ -243,69 +238,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default ) eid = str(uuid.uuid4()) - d = e.model_dump() with get_db() as conn: cur = get_cursor(conn) - 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,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain, - temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_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,%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.get("hr_min"), - d["distance_km"], - d.get("pace_min_per_km"), - d.get("cadence"), - d.get("avg_power"), - d.get("elevation_gain"), - d.get("temperature_celsius"), - d.get("humidity_percent"), - d.get("avg_hr_percent"), - d.get("kcal_per_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: - # Load the activity data to evaluate - 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 - """, (eid,)) - activity_row = cur.fetchone() - if activity_row: - activity_dict = dict(activity_row) - training_type_id = activity_dict.get("training_type_id") - if training_type_id: - try: - evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid) - logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT") - 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) + insert_activity_from_entry(cur, pid, eid, e) + run_activity_post_write_hooks(cur, pid, eid) # Phase 2: Increment usage counter (always for new entries) increment_feature_usage(pid, 'activity_entries') @@ -414,38 +350,26 @@ def list_uncategorized_activities( return [r2d(r) for r in cur.fetchall()] +@router.get("/mappable-fields") +def get_activity_mappable_fields(session: dict = Depends(require_auth)): + """ + Vollständiger Katalog für Import-Mappings (activity_log-Kernfelder + alle aktiven training_parameters). + Werte für Keys ohne Schema zur konkreten Session werden beim Import ignoriert. + """ + pid = str(session["profile_id"]) + with get_db() as conn: + cur = get_cursor(conn) + return get_mappable_activity_field_catalog(cur, pid) + + @router.put("/{eid}") def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Update existing activity entry.""" pid = get_pid(x_profile_id) with get_db() as conn: - d = e.model_dump() cur = get_cursor(conn) - cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s", - list(d.values())+[eid,pid]) - - # Phase 1.2: Auto-evaluation after UPDATE - if EVALUATION_AVAILABLE: - # Load the updated activity data to evaluate - 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 - """, (eid,)) - activity_row = cur.fetchone() - if activity_row: - activity_dict = dict(activity_row) - training_type_id = activity_dict.get("training_type_id") - if training_type_id: - try: - evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid) - logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE") - 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) + update_activity_from_entry(cur, pid, eid, e) + run_activity_post_write_hooks(cur, pid, eid) return {"id":eid} @@ -647,7 +571,10 @@ def bulk_categorize_activities( @router.post("/import-csv") async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Import Apple Health workout CSV with automatic training type mapping.""" + """ + Legacy-Upload (Apple Health Workout-CSV-Spaltennamen). + Persistenz läuft über activity_persistence_orchestrator — gleiche Schicht wie Universal-CSV. + """ pid = get_pid(x_profile_id) raw = await file.read() try: text = raw.decode('utf-8') @@ -663,7 +590,7 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional start = row.get('Start','').strip() if not wtype or not start: continue - workout_date, workout_start_t = _normalize_apple_health_start(start) + workout_date, workout_start_t = normalize_activity_start(start) if not workout_date: continue dur = row.get('Duration','').strip() @@ -682,111 +609,82 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional # Map activity_type to training_type_id using database mappings training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid) + kcal_a = kj(row.get("Aktive Energie (kJ)", "")) + kcal_r = kj(row.get("Ruheeinträge (kJ)", "")) + hr_av = tf(row.get("Durchschn. Herzfrequenz (count/min)", "")) + hr_mx = tf(row.get("Max. Herzfrequenz (count/min)", "")) + dist_km = tf(row.get("Distanz (km)", "")) try: - # Duplicate detection: normiertes Datum + TIME (Apple-Export kann Start in verschiedenen Formaten liefern) - cur.execute( - """ - SELECT id FROM activity_log - WHERE profile_id = %s AND date = %s::date - AND start_time IS NOT DISTINCT FROM %s::time - """, - (pid, workout_date, workout_start_t), - ) - existing = cur.fetchone() - - if existing: - # Update existing entry (e.g., to add training type mapping) - existing_id = existing['id'] - cur.execute(""" - UPDATE activity_log - SET start_time = %s, - end_time = %s, - activity_type = %s, - duration_min = %s, - kcal_active = %s, - kcal_resting = %s, - hr_avg = %s, - hr_max = %s, - distance_km = %s, - training_type_id = %s, - training_category = %s, - training_subcategory = %s - WHERE id = %s - """, ( - workout_start_t, row.get('End',''), wtype, duration_min, - kj(row.get('Aktive Energie (kJ)','')), - kj(row.get('Ruheeinträge (kJ)','')), - tf(row.get('Durchschn. Herzfrequenz (count/min)','')), - tf(row.get('Max. Herzfrequenz (count/min)','')), - tf(row.get('Distanz (km)','')), - training_type_id, training_category, training_subcategory, - existing_id - )) - skipped += 1 # Count as skipped (not newly inserted) - - # Phase 1.2: Auto-evaluation after CSV import UPDATE - if EVALUATION_AVAILABLE and training_type_id: - try: - # Build activity dict for evaluation - activity_dict = { - "id": existing_id, - "profile_id": pid, - "date": workout_date, - "training_type_id": training_type_id, - "duration_min": duration_min, - "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), - "hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), - "distance_km": tf(row.get('Distanz (km)','')), - "kcal_active": kj(row.get('Aktive Energie (kJ)','')), - "kcal_resting": kj(row.get('Ruheeinträge (kJ)','')), - "rpe": None, - "pace_min_per_km": None, - "cadence": None, - "elevation_gain": None - } - evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid) - logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}") - except Exception as eval_error: - logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}") + existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t) + if existing_id: + update_activity_columns( + cur, + pid, + str(existing_id), + { + "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, + }, + ) + skipped += 1 + run_activity_post_write_hooks_import( + 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: - # Insert new entry - new_id = str(uuid.uuid4()) - 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,source,training_type_id,training_category,training_subcategory,created) - VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""", - (new_id,pid,workout_date,workout_start_t,row.get('End',''),wtype,duration_min, - kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')), - tf(row.get('Durchschn. Herzfrequenz (count/min)','')), - tf(row.get('Max. Herzfrequenz (count/min)','')), - tf(row.get('Distanz (km)','')), - training_type_id,training_category,training_subcategory)) - inserted+=1 - - # Phase 1.2: Auto-evaluation after CSV import INSERT - if EVALUATION_AVAILABLE and training_type_id: - try: - # Build activity dict for evaluation - activity_dict = { - "id": new_id, - "profile_id": pid, - "date": workout_date, - "training_type_id": training_type_id, - "duration_min": duration_min, - "hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')), - "hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')), - "distance_km": tf(row.get('Distanz (km)','')), - "kcal_active": kj(row.get('Aktive Energie (kJ)','')), - "kcal_resting": kj(row.get('Ruheeinträge (kJ)','')), - "rpe": None, - "pace_min_per_km": None, - "cadence": None, - "elevation_gain": None - } - evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid) - logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}") - except Exception as eval_error: - logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}") + new_id = new_activity_id() + insert_activity_csv_minimal( + cur, + pid, + new_id, + date_iso=workout_date, + 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", + ) + 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}") skipped+=1 diff --git a/backend/tests/test_csv_import_executor.py b/backend/tests/test_csv_import_executor.py index f5b10cd..d997920 100644 --- a/backend/tests/test_csv_import_executor.py +++ b/backend/tests/test_csv_import_executor.py @@ -249,11 +249,18 @@ def test_run_universal_import_activity_garmin_time_plus_date_columns(monkeypatch cur = _SeqCursor([None, {"id": new_id}]) out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping) assert out["rows_imported"] == 1 - # Duplicate-Key muss Datum + kombinierte Startzeit enthalten - assert any( - params and "2024-01-20 08:30:00" in str(params) + # Duplicate-Check: Datum + TIME, IS NOT DISTINCT FROM (kein String-Vergleich für start_time) + dup_sqls = [ + (_sql, params) for _sql, params in cur.executes - if params + if params and "IS NOT DISTINCT FROM" in _sql and "activity_log" in _sql + ] + assert dup_sqls, f"erwarteter Duplicate-SELECT fehlt; executes={cur.executes!r}" + assert any( + len(p) >= 3 + and str(p[1]).startswith("2024-01-20") + and (getattr(p[2], "hour", None) == 8 and getattr(p[2], "minute", None) == 30) + for _, p in dup_sqls ) From 574af613493352249af2f4092ebe96ff07991f5a Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 08:12:58 +0200 Subject: [PATCH 12/17] feat: Enhance CSV import and validation for activity module - Updated the CSV import logic to merge active training parameters with static fields for the activity module, improving field mapping accuracy. - Enhanced validation functions to incorporate dynamic field definitions based on active training parameters, ensuring better data integrity during imports. - Refactored related functions to streamline the process of handling CSV templates and field mappings, improving maintainability and clarity. - Added new utility functions for resolving activity log column patches and upserting session metrics from CSV, enhancing the overall import functionality. --- backend/csv_parser/executor.py | 54 +++++---- backend/csv_parser/import_row_processing.py | 4 + backend/csv_parser/mapping_suggest.py | 38 +++++- backend/csv_parser/module_registry.py | 5 +- backend/csv_parser/template_validator.py | 12 +- .../activity_persistence_orchestrator.py | 45 +++++++ .../data_layer/activity_session_metrics.py | 112 +++++++++++++++++- backend/db.py | 10 +- backend/main.py | 4 +- backend/routers/admin_csv_templates.py | 73 ++++++++---- backend/routers/csv_import.py | 48 +++++--- 11 files changed, 325 insertions(+), 80 deletions(-) diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 10169a0..3e6fa67 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -814,6 +814,10 @@ def _import_activity( run_activity_post_write_hooks_import, update_activity_columns, ) + from data_layer.activity_session_metrics import ( + resolve_activity_log_column_patch_from_csv, + upsert_session_metrics_from_csv_mapped, + ) rows_total = 0 inserted = 0 @@ -894,29 +898,29 @@ def _import_activity( training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity( cur, wtype, profile_id ) + column_patch = resolve_activity_log_column_patch_from_csv( + cur, mapped, training_category, training_type_id + ) existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t) if existing_id: - update_activity_columns( - cur, - profile_id, - existing_id, - { - "start_time": workout_start_t, - "end_time": end_str or None, - "activity_type": wtype, - "duration_min": duration_min, - "kcal_active": kcal_a, - "kcal_resting": kcal_r, - "hr_avg": hr_a, - "hr_max": hr_m, - "distance_km": dist, - "training_type_id": training_type_id, - "training_category": training_category, - "training_subcategory": training_subcategory, - "source": "csv", - }, - ) + upd = { + "start_time": workout_start_t, + "end_time": end_str or None, + "activity_type": wtype, + "duration_min": duration_min, + "kcal_active": kcal_a, + "kcal_resting": kcal_r, + "hr_avg": hr_a, + "hr_max": hr_m, + "distance_km": dist, + "training_type_id": training_type_id, + "training_category": training_category, + "training_subcategory": training_subcategory, + "source": "csv", + } + upd.update(column_patch) + update_activity_columns(cur, profile_id, existing_id, upd) updated += 1 affected_ids["activity_log"].append(str(existing_id)) aid = existing_id @@ -945,6 +949,8 @@ def _import_activity( new_entries += 1 affected_ids["activity_log"].append(str(eid)) aid = eid + if column_patch: + update_activity_columns(cur, profile_id, aid, column_patch) run_activity_post_write_hooks_import( cur, @@ -959,6 +965,14 @@ def _import_activity( kcal_active=kcal_a, kcal_resting=kcal_r, ) + upsert_session_metrics_from_csv_mapped( + cur, + profile_id, + str(aid), + mapped, + training_category, + training_type_id, + ) cur.execute("RELEASE SAVEPOINT csv_activity_row") except Exception as e: try: diff --git a/backend/csv_parser/import_row_processing.py b/backend/csv_parser/import_row_processing.py index b33dbad..2aa1c55 100644 --- a/backend/csv_parser/import_row_processing.py +++ b/backend/csv_parser/import_row_processing.py @@ -37,12 +37,16 @@ def validate_import_row_processing( module: str, spec: Mapping[str, Any], field_mappings: Mapping[str, Any], + cur=None, ) -> None: """Wirft ValueError bei ungültiger Konfiguration.""" mod = get_module_definition(module) if not mod: raise ValueError(f"Unbekanntes Modul: {module}") allowed = set(mod.get("fields") or []) + if module == "activity" and cur is not None: + cur.execute("SELECT key FROM training_parameters WHERE is_active = true") + allowed.update(str(r["key"]) for r in cur.fetchall()) fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")} group_by = spec.get("group_by") or [] diff --git a/backend/csv_parser/mapping_suggest.py b/backend/csv_parser/mapping_suggest.py index 57f42f7..905bf44 100644 --- a/backend/csv_parser/mapping_suggest.py +++ b/backend/csv_parser/mapping_suggest.py @@ -127,13 +127,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No return None -def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None: +def _alias_suggest( + norm: str, + module: str, + used: set[str], + *, + field_order: list[str] | None = None, +) -> str | None: aliases = _MODULE_HEADER_ALIASES.get(module, {}) mod = get_module_definition(module) if not mod: return None - field_order = list(mod["fields"].keys()) - for db_field in field_order: + order = field_order if field_order is not None else list(mod["fields"].keys()) + for db_field in order: if db_field in used: continue tokens = aliases.get(db_field, frozenset()) @@ -152,6 +158,8 @@ def suggest_field_mappings( headers: list[str], module: str, seed_fm: Mapping[str, str] | None = None, + *, + effective_fields: Mapping[str, Any] | None = None, ) -> dict[str, str]: """ Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'. @@ -164,13 +172,16 @@ def suggest_field_mappings( if not mod: return {h: "-" for h in headers} + fields_map = dict(effective_fields) if effective_fields is not None else dict(mod["fields"]) + field_order = list(fields_map.keys()) + fm: dict[str, str] = {h: "-" for h in headers} used: set[str] = set() if seed_fm: for h in headers: db = _match_seed_to_db_field(h, seed_fm) - if db and db not in used: + if db and db not in used and db in fields_map: fm[h] = db used.add(db) @@ -178,7 +189,7 @@ def suggest_field_mappings( if fm[h] != "-": continue norm = _norm_key(h) - db = _alias_suggest(norm, module, used) + db = _alias_suggest(norm, module, used, field_order=field_order) if db: fm[h] = db used.add(db) @@ -190,6 +201,8 @@ def build_type_conversions_for_mapping( module: str, field_mappings: Mapping[str, str], seed_tc: Mapping[str, Any] | None = None, + *, + effective_fields: Mapping[str, Any] | None = None, ) -> dict[str, Any]: """type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults.""" if module == "sleep": @@ -198,6 +211,7 @@ def build_type_conversions_for_mapping( defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {}) out: dict[str, Any] = {} targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")} + field_meta = dict(effective_fields) if effective_fields is not None else None if seed_tc: for k, v in seed_tc.items(): @@ -208,6 +222,20 @@ def build_type_conversions_for_mapping( if t not in out and t in defaults: out[t] = deepcopy(defaults[t]) + for t in sorted(targets): + if t in out: + continue + finfo = (field_meta or {}).get(t) if field_meta else None + if not finfo: + continue + typ = finfo.get("type") + if typ == "int": + out[t] = {"type": "int", "flexible": True} + elif typ == "float": + out[t] = {"type": "float", "decimal_separator": "auto", "flexible": True} + else: + out[t] = {"type": "string"} + _apply_energy_kj_hint_from_headers(module, field_mappings, out) return out diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index 2a07fee..d4e5d2d 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -125,13 +125,16 @@ def list_modules() -> list[str]: return sorted(MODULE_DEFINITIONS.keys()) -def validate_field_mappings(module: str, field_mappings: dict) -> None: +def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None: """Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld.""" mod = get_module_definition(module) if not mod: raise ValueError(f"Unbekanntes Modul: {module}") fields = cast(dict, mod["fields"]) allowed = set(fields.keys()) + if module == "activity" and cur is not None: + cur.execute("SELECT key FROM training_parameters WHERE is_active = true") + allowed.update(str(r["key"]) for r in cur.fetchall()) if not allowed: for _csv_col, db_field in field_mappings.items(): if db_field not in ("", None, "-", "_skip"): diff --git a/backend/csv_parser/template_validator.py b/backend/csv_parser/template_validator.py index bbee6c9..774ab44 100644 --- a/backend/csv_parser/template_validator.py +++ b/backend/csv_parser/template_validator.py @@ -15,6 +15,7 @@ from csv_parser.module_registry import ( validate_field_mappings, validate_required_field_targets, ) +from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields ALLOWED_SPEC_TYPES = frozenset( {"string", "float", "number", "int", "date", "time", "datetime", "duration"} @@ -50,6 +51,8 @@ def validate_csv_template( type_conversions: Mapping[str, Any] | None = None, import_row_processing: Mapping[str, Any] | None = None, column_signature: list[str] | None = None, + *, + cur=None, ) -> dict[str, Any]: """ Prüft eine Vorlage ohne Datei-Upload. @@ -74,8 +77,12 @@ def validate_csv_template( ) return {"valid": False, "errors": errors, "warnings": warnings} + field_defs = dict(mod.get("fields") or {}) + if module == "activity" and cur is not None: + field_defs = merge_activity_csv_module_fields(cur, field_defs) + try: - validate_field_mappings(module, fm) + validate_field_mappings(module, fm, cur=cur) except ValueError as e: errors.append( _issue( @@ -100,7 +107,7 @@ def validate_csv_template( if import_row_processing: try: - validate_import_row_processing_spec(module, import_row_processing, fm) + validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur) except ValueError as e: errors.append( _issue( @@ -111,7 +118,6 @@ def validate_csv_template( ) ) - field_defs = mod.get("fields") or {} for db_field, spec in tc.items(): if db_field not in field_defs: errors.append( diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 6e74242..6c4aa82 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -227,6 +227,51 @@ def run_activity_post_write_hooks_import( sync_column_backed_session_metrics(cur, str(profile_id), str(eid)) +def merge_activity_csv_module_fields( + cur, + static_fields: Dict[str, Any], +) -> Dict[str, Any]: + """ + activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters. + + Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters — erscheint + in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation. + """ + out = dict(static_fields) + cur.execute( + """ + SELECT key, data_type, unit, name_de + FROM training_parameters + WHERE is_active = true + ORDER BY key + """ + ) + for row in cur.fetchall(): + k = row["key"] + if k in out: + continue + dt = row["data_type"] or "float" + if dt == "integer": + mtype = "int" + elif dt == "float": + mtype = "float" + elif dt == "boolean": + mtype = "string" + else: + mtype = "string" + spec: Dict[str, Any] = { + "type": mtype, + "required": False, + "from_training_parameter": True, + } + if row.get("unit"): + spec["unit"] = row["unit"] + if row.get("name_de"): + spec["label_de"] = row["name_de"] + out[k] = spec + return out + + def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]: """ Felder für konfigurierbare Import-Mappings. diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index a2459d2..de54f40 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -7,10 +7,27 @@ from __future__ import annotations import logging from decimal import Decimal -from typing import Any, Dict, List, Optional, Sequence +from typing import Any, Dict, List, Mapping, Optional, Sequence logger = logging.getLogger(__name__) +# activity_log-Spalten (ohne Kernfelder aus CSV-Minimal-Insert), die über source_field beschrieben werden können. +ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset( + { + "hr_min", + "pace_min_per_km", + "cadence", + "avg_power", + "elevation_gain", + "temperature_celsius", + "humidity_percent", + "avg_hr_percent", + "kcal_per_km", + "rpe", + "notes", + } +) + class ActivitySessionMetricsError(Exception): """Raised by Layer 1; routers map to HTTP (404/400).""" @@ -218,6 +235,99 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any: raise ValueError(data_type) +def resolve_activity_log_column_patch_from_csv( + cur, + mapped: Mapping[str, Any], + training_category: Optional[str], + training_type_id: Optional[int], +) -> Dict[str, Any]: + """ + Zusätzliche activity_log-Updates aus CSV: Parameter mit source_field → Spalte. + """ + schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) + patch: Dict[str, Any] = {} + for spec in schema: + src_col = (spec.get("source_field") or "").strip() + if not src_col or src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS: + continue + pkey = spec["key"] + if pkey not in mapped: + continue + raw = mapped[pkey] + if raw is None or raw == "": + continue + dt = spec["data_type"] + rules = _validation_rules_dict(spec["validation_rules"]) + try: + coerced = _coerce_raw_value_for_parameter(dt, raw) + _validate_single_value(dt, coerced, rules) + except (ActivitySessionMetricsError, TypeError, ValueError) as ex: + logger.warning( + "CSV activity_log patch skipped %s → %s: %s", + pkey, + src_col, + ex, + ) + continue + patch[src_col] = coerced + return patch + + +def upsert_session_metrics_from_csv_mapped( + cur, + profile_id: str, + activity_log_id: str, + mapped: Mapping[str, Any], + training_category: Optional[str], + training_type_id: Optional[int], +) -> None: + """EAV nur für Schema-Parameter ohne source_field (reine Session-Metriken).""" + cur.execute( + "SELECT profile_id FROM activity_log WHERE id = %s", + (activity_log_id,), + ) + row = cur.fetchone() + if not row or str(row["profile_id"]) != str(profile_id): + return + schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) + for spec in schema: + pkey = spec["key"] + if pkey not in mapped: + continue + raw = mapped[pkey] + if raw is None or raw == "": + continue + src_col = (spec.get("source_field") or "").strip() + if src_col: + continue + tid = spec["training_parameter_id"] + dt = spec["data_type"] + rules = _validation_rules_dict(spec["validation_rules"]) + try: + coerced = _coerce_raw_value_for_parameter(dt, raw) + _validate_single_value(dt, coerced, rules) + except (ActivitySessionMetricsError, TypeError, ValueError) as ex: + logger.warning("CSV EAV skipped %s: %s", pkey, 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 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 diff --git a/backend/db.py b/backend/db.py index 9699bae..0b07348 100644 --- a/backend/db.py +++ b/backend/db.py @@ -29,7 +29,9 @@ def init_pool(): user=os.getenv("DB_USER", "mitai"), password=os.getenv("DB_PASSWORD", "") ) - print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})") + print( + f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})" + ) @contextmanager @@ -171,7 +173,7 @@ def init_db(): ) as table_exists """) if not cur.fetchone()['table_exists']: - print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation") + print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation") return # Ensure "pipeline" master prompt exists @@ -189,7 +191,7 @@ def init_db(): ) """) conn.commit() - print("✓ Pipeline master prompt created") + print("[OK] Pipeline master prompt created") except Exception as e: - print(f"⚠️ Could not create pipeline prompt: {e}") + print(f"[WARN] Could not create pipeline prompt: {e}") # Don't fail startup - prompt can be created manually diff --git a/backend/main.py b/backend/main.py index 3a0826b..c6ee964 100644 --- a/backend/main.py +++ b/backend/main.py @@ -67,7 +67,7 @@ async def startup_event(): try: init_db() except Exception as e: - print(f"⚠️ init_db() failed (non-fatal): {e}") + print(f"[WARN] init_db() failed (non-fatal): {e}") # Don't crash on startup - can be created manually # Apply v9c migration if needed @@ -75,7 +75,7 @@ async def startup_event(): from apply_v9c_migration import apply_migration apply_migration() except Exception as e: - print(f"⚠️ v9c migration failed (non-fatal): {e}") + print(f"[WARN] v9c migration failed (non-fatal): {e}") # ── Register Routers ────────────────────────────────────────────────────────── app.include_router(auth.router) # /api/auth/* diff --git a/backend/routers/admin_csv_templates.py b/backend/routers/admin_csv_templates.py index 2b3b7c7..a89c343 100644 --- a/backend/routers/admin_csv_templates.py +++ b/backend/routers/admin_csv_templates.py @@ -25,6 +25,7 @@ from csv_parser.import_row_processing import ( ) from csv_parser.module_registry import get_module_definition from csv_parser.template_validator import validate_csv_template +from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"]) @@ -181,6 +182,8 @@ async def admin_analyze_csv_for_template( sig = column_signature(headers) seed_row: dict | None = None + field_mappings: dict = {} + type_conversions: dict = {} with get_db() as conn: cur = get_cursor(conn) if seed_template_id is not None: @@ -215,15 +218,30 @@ async def admin_analyze_csv_for_template( if best and best_key[0] > 0: seed_row = best - seed_fm = (seed_row or {}).get("field_mappings") or {} - if isinstance(seed_fm, str): - seed_fm = {} - seed_tc = (seed_row or {}).get("type_conversions") - if not isinstance(seed_tc, dict): - seed_tc = {} + mod_def = get_module_definition(module) or {} + eff_fields = dict(mod_def.get("fields") or {}) + if module == "activity": + eff_fields = merge_activity_csv_module_fields(cur, eff_fields) - field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None) - type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None) + seed_fm = (seed_row or {}).get("field_mappings") or {} + if isinstance(seed_fm, str): + seed_fm = {} + seed_tc = (seed_row or {}).get("type_conversions") + if not isinstance(seed_tc, dict): + seed_tc = {} + + field_mappings = suggest_field_mappings( + headers, + module, + seed_fm if seed_fm else None, + effective_fields=eff_fields, + ) + type_conversions = build_type_conversions_for_mapping( + module, + field_mappings, + seed_tc if seed_tc else None, + effective_fields=eff_fields, + ) seed_meta = None if seed_row: @@ -270,13 +288,16 @@ def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dic """ if not get_module_definition(body.module): raise HTTPException(400, f"Unbekanntes Modul: {body.module}") - return validate_csv_template( - body.module, - body.field_mappings, - body.type_conversions, - body.import_row_processing, - body.column_signature, - ) + with get_db() as conn: + cur = get_cursor(conn) + return validate_csv_template( + body.module, + body.field_mappings, + body.type_conversions, + body.import_row_processing, + body.column_signature, + cur=cur, + ) @router.get("/{template_id}") @@ -297,18 +318,19 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin) def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)): if not get_module_definition(body.module): raise HTTPException(400, f"Unbekanntes Modul: {body.module}") - report = validate_csv_template( - body.module, - body.field_mappings, - body.type_conversions, - body.import_row_processing, - body.column_signature, - ) - if not report["valid"]: - raise HTTPException(status_code=422, detail=report) - with get_db() as conn: cur = get_cursor(conn) + report = validate_csv_template( + body.module, + body.field_mappings, + body.type_conversions, + body.import_row_processing, + body.column_signature, + cur=cur, + ) + if not report["valid"]: + raise HTTPException(status_code=422, detail=report) + cur.execute( """ INSERT INTO csv_field_mappings ( @@ -367,6 +389,7 @@ def update_system_template( tc_eff, irp_eff, col_eff if isinstance(col_eff, list) else None, + cur=cur, ) if not report["valid"]: raise HTTPException(status_code=422, detail=report) diff --git a/backend/routers/csv_import.py b/backend/routers/csv_import.py index 5f618da..b3ab67a 100644 --- a/backend/routers/csv_import.py +++ b/backend/routers/csv_import.py @@ -34,6 +34,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp from csv_parser.field_units import source_unit_choices_for_field from csv_parser.import_errors import enrich_row_error from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings +from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format router = APIRouter(prefix="/api/csv", tags=["csv-import"]) @@ -66,25 +67,30 @@ def _mapping_to_summary(m: dict) -> dict: def csv_modules(session: dict = Depends(require_auth)): """Unterstützte Import-Module und Felddefinitionen.""" out = [] - for mid in list_modules(): - d = get_module_definition(mid) - if d: - fields_out = {} - for fname, finfo in (d.get("fields") or {}).items(): - fd = dict(finfo) - opts = source_unit_choices_for_field(mid, fname) - if opts: - fd["source_unit_options"] = opts - fields_out[fname] = fd - out.append( - { - "id": mid, - "table": d["table"], - "fields": fields_out, - "import_mode": d.get("import_mode"), - "import_row_processing_default": d.get("import_row_processing_default"), - } - ) + with get_db() as conn: + cur = get_cursor(conn) + for mid in list_modules(): + d = get_module_definition(mid) + if d: + field_src = dict(d.get("fields") or {}) + if mid == "activity": + field_src = merge_activity_csv_module_fields(cur, field_src) + fields_out = {} + for fname, finfo in field_src.items(): + fd = dict(finfo) + opts = source_unit_choices_for_field(mid, fname) + if opts: + fd["source_unit_options"] = opts + fields_out[fname] = fd + out.append( + { + "id": mid, + "table": d["table"], + "fields": fields_out, + "import_mode": d.get("import_mode"), + "import_row_processing_default": d.get("import_row_processing_default"), + } + ) return {"modules": out} @@ -257,6 +263,10 @@ async def analyze_csv( with get_db() as conn: cur = get_cursor(conn) + if module == "activity" and mod_def: + available_fields = merge_activity_csv_module_fields( + cur, dict(mod_def.get("fields") or {}) + ) if module: cur.execute( """ From c570e67a0909449adfcbffa9260e4b21ffee17bb Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 08:55:43 +0200 Subject: [PATCH 13/17] feat: Enhance activity session metrics handling and frontend display - Updated the `ACTIVITY_LOG_PATCHABLE_COLUMNS` and `ACTIVITY_LOG_PATCH_FORBIDDEN` sets to improve validation of CSV imports, ensuring only allowed fields are patched. - Refactored the `_coerce_raw_value_for_parameter` function to handle string inputs for integer and float types, enhancing data coercion accuracy. - Modified the `SessionMetricsFields` component to display orphan metrics that do not match the current schema, improving user visibility of imported data discrepancies. - Enhanced the frontend to handle and display additional metrics, ensuring a more comprehensive representation of session data. --- .../data_layer/activity_session_metrics.py | 38 ++++++++++++-- frontend/src/pages/ActivityPage.jsx | 50 +++++++++++++++++-- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index de54f40..6e3de0e 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -11,10 +11,21 @@ from typing import Any, Dict, List, Mapping, Optional, Sequence logger = logging.getLogger(__name__) -# activity_log-Spalten (ohne Kernfelder aus CSV-Minimal-Insert), die über source_field beschrieben werden können. +# activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen. +# Muss mit sync_column_backed_session_metrics übereinstimmen (inkl. Kernmetriken wie hr_avg). ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset( { + "start_time", + "end_time", + "activity_type", + "duration_min", + "kcal_active", + "kcal_resting", + "hr_avg", + "hr_max", "hr_min", + "distance_km", + "rpe", "pace_min_per_km", "cadence", "avg_power", @@ -23,11 +34,24 @@ ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset( "humidity_percent", "avg_hr_percent", "kcal_per_km", - "rpe", "notes", } ) +# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System). +ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset( + { + "id", + "profile_id", + "date", + "created", + "training_type_id", + "training_category", + "training_subcategory", + "source", + } +) + class ActivitySessionMetricsError(Exception): """Raised by Layer 1; routers map to HTTP (404/400).""" @@ -218,8 +242,14 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any: if data_type == "integer": if isinstance(raw, bool): raise TypeError("boolean nicht als integer erlaubt") + if isinstance(raw, str): + s = raw.strip().replace(",", ".") + return int(round(float(s))) return int(round(float(raw))) if data_type == "float": + if isinstance(raw, str): + s = raw.strip().replace(",", ".") + return float(s) return float(raw) if data_type == "string": return str(raw) if raw is not None else "" @@ -248,7 +278,9 @@ def resolve_activity_log_column_patch_from_csv( patch: Dict[str, Any] = {} for spec in schema: src_col = (spec.get("source_field") or "").strip() - if not src_col or src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS: + if not src_col or src_col in ACTIVITY_LOG_PATCH_FORBIDDEN: + continue + if src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS: continue pkey = spec["key"] if pkey not in mapped: diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index a53a58f..c38283e 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -118,13 +118,18 @@ function buildMetricsPayload(schema, draft) { return out } -function SessionMetricsFields({ schema, values, setValues }) { - if (!schema || schema.length === 0) return null +function SessionMetricsFields({ schema, values, setValues, metrics }) { + const schemaList = Array.isArray(schema) ? schema : [] + 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)) + + if (schemaList.length === 0 && orphanMetrics.length === 0) return null const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v })) return (
Weitere Kennwerte (Profil)
- {schema.map((s) => ( + {schemaList.map((s) => (
))} + {orphanMetrics.length > 0 && ( +
+
+ Werte aus Import/älteren Daten, die zum aktuellen Trainingsprofil dieser Session (Kategorie/Typ + in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der + Datenbank stehen. +
+ {orphanMetrics.map((row) => { + const disp = + values[row.key] === null || values[row.key] === undefined || values[row.key] === '' + ? '—' + : String(values[row.key]) + return ( +
+ + {row.data_type === 'boolean' ? ( + + ) : ( +
+ {disp} +
+ )} + +
+ ) + })} +
+ )}
) } @@ -699,6 +742,7 @@ export default function ActivityPage() { )} From e4e8c70cd29883bb1866292b2fee277495f28fd7 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 10:04:32 +0200 Subject: [PATCH 14/17] feat: Enhance CSV header normalization and mapping for activity data - Introduced a new utility function `canonical_csv_header_label` to standardize CSV header labels, improving consistency in field mapping. - Updated the `_lookup_db_field` function to support prefix matching for longer manual keys, enhancing the accuracy of field resolution. - Added tests to validate handling of non-breaking space characters in CSV headers and ensure correct mapping to normalized keys, improving robustness of CSV parsing. --- backend/csv_parser/core.py | 32 ++++++++++++++++++-------- backend/csv_parser/type_converter.py | 28 ++++++++++++++++++++++- backend/tests/test_csv_parser_core.py | 33 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/backend/csv_parser/core.py b/backend/csv_parser/core.py index ed449b8..eb23a9f 100644 --- a/backend/csv_parser/core.py +++ b/backend/csv_parser/core.py @@ -57,6 +57,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]: return lines +def canonical_csv_header_label(name: str | None) -> str: + """ + Einheitlicher Spalten-Key für Analyse (Vorlage/Dialog), Import und Signatur. + BOM und NBSP (häufig in Excel/Apple-Exporten) werden vereinheitlicht, damit + field_mappings exakt zu DictReader-Zeilen passt. + """ + if name is None: + return "" + s = str(name).replace("\ufeff", "").replace("\u00a0", " ").strip() + return s + + def parse_csv_sample( text: str, delimiter: str | None = None, @@ -85,7 +97,7 @@ def parse_csv_sample( return [], [], delim if has_header: - headers = [h.strip() for h in rows_raw[0]] + headers = [canonical_csv_header_label(h) for h in rows_raw[0]] data = rows_raw[1 : 1 + max_data_rows] else: n = len(rows_raw[0]) @@ -103,7 +115,7 @@ def parse_csv_sample( def normalize_header_for_signature(name: str) -> str: - s = name.strip().lower() + s = canonical_csv_header_label(name).lower() s = re.sub(r"\s+", "_", s) s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s) return s.strip("_") @@ -111,7 +123,9 @@ def normalize_header_for_signature(name: str) -> str: def column_signature(headers: List[str]) -> List[str]: """Sortierte normalisierte Spaltennamen für Signatur-Vergleich.""" - return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()}) + return sorted( + {normalize_header_for_signature(h) for h in headers if h is not None and canonical_csv_header_label(str(h))} + ) def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float: @@ -178,12 +192,6 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]: return defaults -def _strip_header_key(k: str | None) -> str: - if k is None: - return "" - return str(k).strip().removeprefix("\ufeff") - - def iter_csv_dict_rows( text: str, delimiter: str, @@ -205,4 +213,8 @@ def iter_csv_dict_rows( continue if not any(v and str(v).strip() for v in row.values()): continue - yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)} + yield { + canonical_csv_header_label(k): (v or "").strip() + for k, v in row.items() + if canonical_csv_header_label(k) + } diff --git a/backend/csv_parser/type_converter.py b/backend/csv_parser/type_converter.py index 8d59e5e..5a55832 100644 --- a/backend/csv_parser/type_converter.py +++ b/backend/csv_parser/type_converter.py @@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence from dateutil import parser as dateutil_parser -from csv_parser.core import normalize_header_for_signature +from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature from csv_parser.field_units import factor_source_to_canonical # Alias → strptime (JSON in Kleinbuchstaben) @@ -477,7 +477,12 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert (wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung, dann Abgleich aller Vorlagen-Keys über deren Normalform. + + Zusätzlich: Präfix-Treffer für lange manuelle Keys (z. B. Apple + „Aufgestiegene Höhe (m)“ → ``aufgestiegene_höhe_(m)`` vs. Mapping + „aufgestiegene Höhe“ → ``aufgestiegene_höhe``) — gewinnt der längste passende Key. """ + csv_col = canonical_csv_header_label(csv_col) v = field_mappings.get(csv_col) if v: return v if v not in ("-", "_skip") else None @@ -488,6 +493,27 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N for k, fv in field_mappings.items(): if normalize_header_for_signature(str(k)) == norm: return fv if fv not in ("-", "_skip") else None + + # Präfix-Match (min. Länge gegen false positives wie „datum“ → „datum_xyz“) + best_fv: str | None = None + best_nk_len = 0 + min_prefix = 10 + for k, fv in field_mappings.items(): + if not fv or fv in ("-", "_skip"): + continue + nk = normalize_header_for_signature(str(k)) + if len(nk) < min_prefix or len(nk) >= len(norm): + continue + if not norm.startswith(nk): + continue + boundary = norm[len(nk) : len(nk) + 1] + if boundary not in ("", "_", "("): + continue + if len(nk) > best_nk_len: + best_nk_len = len(nk) + best_fv = fv + if best_fv: + return best_fv return None diff --git a/backend/tests/test_csv_parser_core.py b/backend/tests/test_csv_parser_core.py index 2040395..3e27673 100644 --- a/backend/tests/test_csv_parser_core.py +++ b/backend/tests/test_csv_parser_core.py @@ -38,6 +38,29 @@ def test_parse_csv_sample_header(): assert rows[0]["kcal"] == "2000" +def test_parse_csv_sample_nbsp_in_header_matches_normal_space_key(): + """Excel/Apple: NBSP (U+00A0) im Spaltennamen — gleicher Key wie normales Leerzeichen.""" + text = "Aufgestiegene\u00a0Höhe (m);Wert\n12;3\n" + headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3) + assert headers == ["Aufgestiegene Höhe (m)", "Wert"] + assert rows[0]["Aufgestiegene Höhe (m)"] == "12" + + +def test_iter_csv_dict_rows_nbsp_header_canonical(): + text = "col\u00a0one;b\n1;2\n" + rows = list(iter_csv_dict_rows(text, ";", has_header=True)) + assert rows == [{"col one": "1", "b": "2"}] + + +def test_build_row_field_mapping_space_vs_nbsp_in_csv_header(): + """Vorlage (Dialog) mit normalem Leerzeichen, CSV mit NBSP — Zuordnung muss greifen.""" + csv_row = {"Aufgestiegene\u00a0Höhe (m)": "10"} + fm = {"Aufgestiegene Höhe (m)": "stola"} + tc = {"stola": {"type": "float", "decimal_separator": ".", "flexible": True}} + out = build_row_after_mapping(csv_row, fm, tc, module="activity") + assert out.get("stola") == 10.0 + + def test_column_signature_sorted_unique(): sig = column_signature(["B", "a", "a"]) assert sig == ["a", "b"] @@ -183,6 +206,16 @@ def test_build_row_fddb_raw_header_keys_match_normalized_template(): assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1 +def test_build_row_apple_workout_elevation_header_prefix_matches_shorter_mapping_key(): + """Apple Workouts: „Aufgestiegene Höhe (m)“ normalisiert anders als manuell „aufgestiegene Höhe“.""" + csv_row = {"Aufgestiegene Höhe (m)": "47.13", "Workout Type": "Outdoor Spaziergang"} + fm = {"aufgestiegene Höhe": "stola", "Workout Type": "activity_type"} + tc = {"stola": {"type": "string"}} + out = build_row_after_mapping(csv_row, fm, tc, module="activity") + assert out.get("stola") == "47.13" + assert out.get("activity_type") == "Outdoor Spaziergang" + + def test_convert_date_ddmm_with_seconds(): d = convert_value( "15.01.2024 14:30:00", From 9d47c4ef84bb206ab7125d4a0605bdf1bd88d342 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 10:28:13 +0200 Subject: [PATCH 15/17] feat: Update session metrics handling for CSV-mapped values - Enhanced the docstring for `upsert_session_metrics_from_csv_mapped` to clarify the handling of schema parameters and EAV logic. - Modified the condition for skipping updates based on `source_field` to ensure only patchable columns are processed, improving data integrity during session metrics upsert operations. --- .../data_layer/activity_session_metrics.py | 14 ++++++-- ..._per_km_trigger_no_overwrite_on_update.sql | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/056_activity_kcal_per_km_trigger_no_overwrite_on_update.sql diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 6e3de0e..69109e7 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -313,7 +313,17 @@ def upsert_session_metrics_from_csv_mapped( training_category: Optional[str], training_type_id: Optional[int], ) -> None: - """EAV nur für Schema-Parameter ohne source_field (reine Session-Metriken).""" + """ + EAV für Schema-Parameter aus CSV-mapped Werten. + + Spalten-gestützte Parameter (source_field ∈ ACTIVITY_LOG_PATCHABLE_COLUMNS) werden + ausschließlich über resolve_activity_log_column_patch_from_csv → activity_log + geschrieben — hier kein EAV. + + Wenn source_field gesetzt ist, aber **kein** patchbarer Spaltenname (z. B. eigener + Key „stola“ wie der Parametername), wäre früher weder Spalten-Update noch EAV erfolgt; + dann EAV wie bei reinen Metriken. + """ cur.execute( "SELECT profile_id FROM activity_log WHERE id = %s", (activity_log_id,), @@ -330,7 +340,7 @@ def upsert_session_metrics_from_csv_mapped( if raw is None or raw == "": continue src_col = (spec.get("source_field") or "").strip() - if src_col: + if src_col and src_col in ACTIVITY_LOG_PATCHABLE_COLUMNS: continue tid = spec["training_parameter_id"] dt = spec["data_type"] diff --git a/backend/migrations/056_activity_kcal_per_km_trigger_no_overwrite_on_update.sql b/backend/migrations/056_activity_kcal_per_km_trigger_no_overwrite_on_update.sql new file mode 100644 index 0000000..b129f86 --- /dev/null +++ b/backend/migrations/056_activity_kcal_per_km_trigger_no_overwrite_on_update.sql @@ -0,0 +1,35 @@ +-- Migration 056: kcal_per_km Trigger — manuelles Leeren bei UPDATE erlauben +-- Problem: calculate_avg_hr_percent (014) setzte bei jedem UPDATE kcal_per_km aus +-- kcal_active/distance_km, sobald beide gesetzt waren — ein bewusst geleertes Feld +-- erschien sofort wieder. +-- Lösung: automatische Ableitung nur noch bei INSERT (wenn kcal_per_km noch NULL ist). + +CREATE OR REPLACE FUNCTION calculate_avg_hr_percent() +RETURNS TRIGGER AS $$ +DECLARE + user_max_hr INTEGER; +BEGIN + SELECT hf_max INTO user_max_hr + FROM profiles + WHERE id = NEW.profile_id; + + IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN + NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100; + END IF; + + IF TG_OP = 'INSERT' THEN + IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN + IF NEW.kcal_per_km IS NULL THEN + NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km; + END IF; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + RAISE NOTICE '✓ Migration 056: kcal_per_km nur noch bei INSERT auto-abgeleitet'; +END $$; From 08eae86ddca357689efda5093ced5eaa842ebc26 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 10:35:48 +0200 Subject: [PATCH 16/17] feat: Refactor activity import logic and enhance CSV handling - Replaced the deprecated `resolve_activity_log_column_patch_from_csv` function with `activity_csv_registry_updates_from_mapped` to streamline updates from CSV mappings. - Updated the `_import_activity` function to utilize the new registry updates, improving data integrity during activity imports. - Enhanced the activity module registry by adding German labels for various fields, improving localization support. - Refactored the session metrics handling to ensure only relevant fields are processed, enhancing the overall robustness of CSV imports. --- backend/csv_parser/executor.py | 16 ++-- backend/csv_parser/module_registry.py | 47 +++++++--- .../activity_persistence_orchestrator.py | 88 ++++++++++++++++++- .../data_layer/activity_session_metrics.py | 58 ++---------- frontend/src/pages/ActivityPage.jsx | 26 +++--- .../src/pages/AdminCsvTemplateEditorPage.jsx | 39 ++++++-- 6 files changed, 181 insertions(+), 93 deletions(-) diff --git a/backend/csv_parser/executor.py b/backend/csv_parser/executor.py index 3e6fa67..67c78c7 100644 --- a/backend/csv_parser/executor.py +++ b/backend/csv_parser/executor.py @@ -808,16 +808,14 @@ def _import_activity( ) -> dict[str, int]: from data_layer.activity_time_normalize import normalize_activity_start from data_layer.activity_persistence_orchestrator import ( + activity_csv_registry_updates_from_mapped, find_activity_duplicate_id, insert_activity_csv_minimal, new_activity_id, run_activity_post_write_hooks_import, update_activity_columns, ) - from data_layer.activity_session_metrics import ( - resolve_activity_log_column_patch_from_csv, - upsert_session_metrics_from_csv_mapped, - ) + from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped rows_total = 0 inserted = 0 @@ -898,9 +896,7 @@ def _import_activity( training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity( cur, wtype, profile_id ) - column_patch = resolve_activity_log_column_patch_from_csv( - cur, mapped, training_category, training_type_id - ) + registry_updates = activity_csv_registry_updates_from_mapped(mapped) existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t) if existing_id: @@ -919,7 +915,7 @@ def _import_activity( "training_subcategory": training_subcategory, "source": "csv", } - upd.update(column_patch) + upd.update(registry_updates) update_activity_columns(cur, profile_id, existing_id, upd) updated += 1 affected_ids["activity_log"].append(str(existing_id)) @@ -949,8 +945,8 @@ def _import_activity( new_entries += 1 affected_ids["activity_log"].append(str(eid)) aid = eid - if column_patch: - update_activity_columns(cur, profile_id, aid, column_patch) + if registry_updates: + update_activity_columns(cur, profile_id, aid, registry_updates) run_activity_post_write_hooks_import( cur, diff --git a/backend/csv_parser/module_registry.py b/backend/csv_parser/module_registry.py index d4e5d2d..ab0b0f2 100644 --- a/backend/csv_parser/module_registry.py +++ b/backend/csv_parser/module_registry.py @@ -37,16 +37,43 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = { "activity": { "table": "activity_log", "fields": { - "date": {"type": "date", "required": False}, - "start_time": {"type": "datetime", "required": False}, - "end_time": {"type": "datetime", "required": False}, - "activity_type": {"type": "string", "required": True}, - "duration_min": {"type": "float", "required": False, "min": 0}, - "kcal_active": {"type": "float", "required": False, "unit": "kcal"}, - "kcal_resting": {"type": "float", "required": False, "unit": "kcal"}, - "distance_km": {"type": "float", "required": False, "unit": "km"}, - "hr_avg": {"type": "float", "required": False, "min": 30, "max": 220}, - "hr_max": {"type": "float", "required": False, "min": 30, "max": 220}, + "date": {"type": "date", "required": False, "label_de": "Datum"}, + "start_time": { + "type": "datetime", + "required": False, + "label_de": "Start (Datum/Uhrzeit)", + }, + "end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"}, + "activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"}, + "duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"}, + "kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"}, + "kcal_resting": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien Ruhe"}, + "distance_km": {"type": "float", "required": False, "unit": "km", "label_de": "Distanz (km)"}, + "hr_avg": { + "type": "float", + "required": False, + "min": 30, + "max": 220, + "label_de": "Herzfrequenz Ø (bpm)", + }, + "hr_max": { + "type": "float", + "required": False, + "min": 30, + "max": 220, + "label_de": "Herzfrequenz max (bpm)", + }, + "hr_min": {"type": "int", "required": False, "label_de": "Herzfrequenz min (bpm)"}, + "rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"}, + "pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"}, + "cadence": {"type": "int", "required": False, "label_de": "Kadenz"}, + "avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"}, + "elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"}, + "temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"}, + "humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"}, + "avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"}, + "kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"}, + "notes": {"type": "string", "required": False, "label_de": "Notiz"}, }, "derive_date_from_datetime_field": "start_time", "duplicate_key": ["profile_id", "date", "start_time"], diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 6c4aa82..56d7f04 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -7,9 +7,10 @@ Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog() """ from __future__ import annotations +import datetime as dt import logging import uuid -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Mapping, Optional from models import ActivityEntry @@ -45,6 +46,89 @@ def find_activity_duplicate_id( return str(row["id"]) if row else None +# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben. +_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"}) + + +def activity_registry_field_keys() -> frozenset[str]: + mod = get_module_definition("activity") + if not mod: + return frozenset() + return frozenset((mod.get("fields") or {}).keys()) + + +def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]: + """ + activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten). + Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier. + """ + mod = get_module_definition("activity") + if not mod: + return {} + fields = mod.get("fields") or {} + out: Dict[str, Any] = {} + + def _sf(v: Any) -> float | None: + try: + if v is None or (isinstance(v, str) and not str(v).strip()): + return None + return round(float(v), 1) + except (TypeError, ValueError): + return None + + def _si(v: Any) -> int | None: + try: + if v is None or (isinstance(v, str) and not str(v).strip()): + return None + return int(round(float(v))) + except (TypeError, ValueError): + return None + + def _hr(v: Any) -> float | None: + x = _sf(v) + if x is None or x < 20 or x > 280: + return None + return x + + for key, spec in fields.items(): + if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE: + continue + if key not in mapped: + continue + raw = mapped[key] + if raw is None or raw == "": + continue + if isinstance(raw, str) and not raw.strip(): + continue + typ = spec.get("type", "string") + if typ == "float": + v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw) + if v is not None: + out[key] = v + elif typ == "int": + v = _si(raw) + if v is not None: + out[key] = v + elif typ == "datetime": + if isinstance(raw, dt.datetime): + out[key] = raw.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(raw, dt.date): + out[key] = f"{raw.isoformat()} 00:00:00" + elif isinstance(raw, str) and raw.strip(): + out[key] = raw.strip() + elif typ == "date": + if isinstance(raw, dt.date): + out[key] = raw.isoformat() + elif isinstance(raw, dt.datetime): + out[key] = raw.date().isoformat() + elif isinstance(raw, str) and raw.strip(): + out[key] = raw.strip() + else: + out[key] = str(raw).strip() + + return out + + def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None: """INSERT activity_log aus ActivityEntry (manueller API-Pfad).""" d = e.model_dump() @@ -296,7 +380,7 @@ def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]: "data_type": s.get("type", "string"), "required": bool(s.get("required")), "unit": s.get("unit"), - "label_de": key, + "label_de": s.get("label_de") or key, } ) core_fields.sort(key=lambda x: x["key"]) diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 69109e7..ab3c812 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -9,6 +9,8 @@ import logging from decimal import Decimal from typing import Any, Dict, List, Mapping, Optional, Sequence +from csv_parser.module_registry import get_module_definition + logger = logging.getLogger(__name__) # activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen. @@ -265,46 +267,6 @@ def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any: raise ValueError(data_type) -def resolve_activity_log_column_patch_from_csv( - cur, - mapped: Mapping[str, Any], - training_category: Optional[str], - training_type_id: Optional[int], -) -> Dict[str, Any]: - """ - Zusätzliche activity_log-Updates aus CSV: Parameter mit source_field → Spalte. - """ - schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) - patch: Dict[str, Any] = {} - for spec in schema: - src_col = (spec.get("source_field") or "").strip() - if not src_col or src_col in ACTIVITY_LOG_PATCH_FORBIDDEN: - continue - if src_col not in ACTIVITY_LOG_PATCHABLE_COLUMNS: - continue - pkey = spec["key"] - if pkey not in mapped: - continue - raw = mapped[pkey] - if raw is None or raw == "": - continue - dt = spec["data_type"] - rules = _validation_rules_dict(spec["validation_rules"]) - try: - coerced = _coerce_raw_value_for_parameter(dt, raw) - _validate_single_value(dt, coerced, rules) - except (ActivitySessionMetricsError, TypeError, ValueError) as ex: - logger.warning( - "CSV activity_log patch skipped %s → %s: %s", - pkey, - src_col, - ex, - ) - continue - patch[src_col] = coerced - return patch - - def upsert_session_metrics_from_csv_mapped( cur, profile_id: str, @@ -314,15 +276,10 @@ def upsert_session_metrics_from_csv_mapped( training_type_id: Optional[int], ) -> None: """ - EAV für Schema-Parameter aus CSV-mapped Werten. + EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen). - Spalten-gestützte Parameter (source_field ∈ ACTIVITY_LOG_PATCHABLE_COLUMNS) werden - ausschließlich über resolve_activity_log_column_patch_from_csv → activity_log - geschrieben — hier kein EAV. - - Wenn source_field gesetzt ist, aber **kein** patchbarer Spaltenname (z. B. eigener - Key „stola“ wie der Parametername), wäre früher weder Spalten-Update noch EAV erfolgt; - dann EAV wie bei reinen Metriken. + Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log; + hier keine doppelten EAV-Zeilen für dieselben Registry-Keys. """ cur.execute( "SELECT profile_id FROM activity_log WHERE id = %s", @@ -331,6 +288,8 @@ def upsert_session_metrics_from_csv_mapped( row = cur.fetchone() if not row or str(row["profile_id"]) != str(profile_id): return + mod = get_module_definition("activity") or {} + activity_registry_keys = frozenset((mod.get("fields") or {}).keys()) schema = resolve_activity_attribute_schema(cur, training_category, training_type_id) for spec in schema: pkey = spec["key"] @@ -339,8 +298,7 @@ def upsert_session_metrics_from_csv_mapped( raw = mapped[pkey] if raw is None or raw == "": continue - src_col = (spec.get("source_field") or "").strip() - if src_col and src_col in ACTIVITY_LOG_PATCHABLE_COLUMNS: + if pkey in activity_registry_keys: continue tid = spec["training_parameter_id"] dt = spec["data_type"] diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index c38283e..ec68925 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -98,20 +98,21 @@ function buildMetricsPayload(schema, draft) { out.push({ parameter_key: s.key, value: !!raw }) continue } - if (raw === '' || raw === null || raw === undefined) { + const rawStr = raw === null || raw === undefined ? '' : String(raw).trim() + if (rawStr === '') { if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`) out.push({ parameter_key: s.key, value: null }) continue } - let v = raw + let v if (s.data_type === 'integer') { - v = parseInt(String(raw), 10) + v = parseInt(rawStr, 10) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'float') { - v = parseFloat(String(raw)) + v = parseFloat(rawStr) if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else { - v = String(raw) + v = rawStr } out.push({ parameter_key: s.key, value: v }) } @@ -532,21 +533,22 @@ export default function ActivityPage() { 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) { + const rawStr = raw === null || raw === undefined ? '' : String(raw).trim() + if (rawStr === '') { payload[col] = null continue } - let v = raw + let v = rawStr if (s.data_type === 'integer') { - v = parseInt(String(raw), 10) - if (Number.isNaN(v)) continue + v = parseInt(rawStr, 10) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'float') { - v = parseFloat(String(raw)) - if (Number.isNaN(v)) continue + v = parseFloat(rawStr) + if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`) } else if (s.data_type === 'boolean') { v = !!raw } else { - v = String(raw) + v = rawStr } payload[col] = v } diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 90e6fc3..5440e87 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -297,10 +297,19 @@ export default function AdminCsvTemplateEditorPage() { const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate' const targetOptions = useMemo(() => { if (!modMeta?.fields || aggregateSleepImport) return [] - return Object.entries(modMeta.fields).map(([key, meta]) => ({ - value: key, - label: `${key}${meta.required ? ' *' : ''}`, - })) + const entries = Object.entries(modMeta.fields).map(([key, meta]) => { + const title = meta.label_de || meta.name_de || key + return { + value: key, + label: `${title}${meta.required ? ' *' : ''}`, + group: meta.from_training_parameter ? 'eav' : 'log', + } + }) + entries.sort((a, b) => { + if (a.group !== b.group) return a.group === 'log' ? -1 : 1 + return a.label.localeCompare(b.label, 'de') + }) + return entries }, [modMeta, aggregateSleepImport]) const requiredTargets = useMemo(() => { @@ -1025,11 +1034,23 @@ export default function AdminCsvTemplateEditorPage() { }} > - {targetOptions.map((o) => ( - - ))} + {['log', 'eav'].map((g) => { + const opts = targetOptions.filter((o) => o.group === g) + if (!opts.length) return null + const ogLabel = + g === 'log' + ? 'Activity — Kernfelder (activity_log)' + : 'Trainingsparameter (EAV)' + return ( + + {opts.map((o) => ( + + ))} + + ) + })}
))} From 58ddde6b1ebfd269aaffff444c0e9c69305a2dc5 Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 15 Apr 2026 11:39:39 +0200 Subject: [PATCH 17/17] feat: Add time input fields for activity log and enhance time handling - Introduced start_time and end_time fields in the activity log entry form, allowing users to input specific times for activities. - Implemented utility functions to format and validate time inputs from the API and user input, ensuring proper handling of time data. - Updated the activity display to show formatted start and end times, improving clarity for users reviewing their activity logs. --- frontend/src/pages/ActivityPage.jsx | 72 ++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index ec68925..50ea792 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -38,6 +38,29 @@ function dedupeActivitiesById(rows) { return [...m.values()].sort(compareActivities) } +/** activity_log: Spalten start_time / end_time sind TIME (Uhrzeit zum Kalendertag date), nicht volles Timestamp. */ +function timeInputValueFromApi(t) { + if (t == null || t === '') return '' + const s = String(t) + if (s.includes('T') && s.length >= 16) return s.slice(11, 16) + const m = s.match(/^(\d{1,2}):(\d{2})/) + if (!m) return '' + return `${m[1].padStart(2, '0')}:${m[2]}` +} + +function timePayloadFromInput(v) { + const s = v == null ? '' : String(v).trim() + if (!s) return null + if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00` + if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s + return s +} + +function formatTimeForList(t) { + const v = timeInputValueFromApi(t) + return v || '' +} + const ACTIVITY_TYPES = [ 'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang', 'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen', @@ -76,6 +99,8 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([ function empty() { return { date: dayjs().format('YYYY-MM-DD'), + start_time: '', + end_time: '', activity_type: 'Traditionelles Krafttraining', duration_min: '', kcal_active: '', hr_avg: '', hr_max: '', rpe: '', notes: '', @@ -282,6 +307,30 @@ function EntryForm({ set('date',e.target.value)}/>
+
+ + set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')} + /> + zum Datum oben +
+
+ + set('end_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')} + /> + optional +
0) { const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft) @@ -837,7 +902,12 @@ export default function ActivityPage() {
{dayjs(e.date).format('dd, DD. MMMM YYYY')} - {e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`} + {(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && ( + + {formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`} + {formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`} + + )}
{e.duration_min && ⏱ {Math.round(e.duration_min)} Min}