diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md index fe0e2f9..e4fd5fd 100644 --- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -129,7 +129,7 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel **Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“. -**Audit-Stand (2026-04-16):** +**Audit-Stand (2026-04-16, ergänzt Export):** | Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung | |----------|---------------------------------------------------------------------------------------------|-----------| @@ -138,6 +138,9 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel | `activity_metrics.get_activity_detail_data` | ✅ | Platzhalter `{{activity_detail}}` | | `activity_metrics.get_training_sessions_recent_weeks_data` | ✅ | KI-Kontext | | `placeholder_resolver` (Aktivität) | ✅ nur `activity_metrics` | kein paralleles SQL | +| `GET /api/export/json` (`activity`) | ✅ `enrich_sessions_with_metrics` + `serialize_dates` | `session_metrics` pro Zeile | +| `GET /api/export/csv` (Training-Zeilen) | ✅ `enrich_sessions_with_metrics` | gemergte EAV in Spalte „Details“ | +| `GET /api/export/zip` (`data/activity.csv`) | ✅ `enrich_sessions_with_metrics` | Zusatzspalte `session_metrics_json` (Import ignoriert sie) | | `get_activity_summary_data` | n. a. | rein aggregiert (`SUM`/`COUNT`), keine Session-EAV | | `routers/charts.py` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts | | `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten | @@ -197,4 +200,4 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel --- -**Version:** 1.2 · §2.1a Navigationsregel (Read-Merge vs. Orchestrator-Schreiben vs. `activity_metrics`-Berechnungen). +**Version:** 1.3 · Phase-B-Export an `enrich_sessions_with_metrics` angebunden (`exportdata.py`). diff --git a/CLAUDE.md b/CLAUDE.md index 1ebf572..b863774 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,7 @@ frontend/src/ - **Phase A:** Skalar-Kanon schriftlich fixiert — `.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md`; `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` v1.1; Agent-Guide Checkliste Phase A erledigt. - **Phase B:** `GET /api/activity` (Liste) reichert jede Zeile mit `session_metrics` über `enrich_sessions_with_metrics` an (gleiche Merge-Logik wie Detail); Consumer-Audit-Tabelle in Produktions-Architektur-Dok §4 Phase B. +- **Phase B (Export):** `routers/exportdata.py` — JSON-Export `activity` mit `session_metrics`; CSV-Gesamtexport Training-Details mit EAV-Zusammenfassung; ZIP `data/activity.csv` mit Zusatzspalte `session_metrics_json` (Standard-Import unverändert). ### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score) diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index a0dd4b2..dc0fa53 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -22,6 +22,8 @@ from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid from feature_logger import log_feature_usage from caliper_composition import enrich_caliper_row_for_response, load_weight_rows +from data_layer.activity_session_metrics import enrich_sessions_with_metrics +from data_layer.utils import serialize_dates router = APIRouter(prefix="/api/export", tags=["export"]) logger = logging.getLogger(__name__) @@ -90,10 +92,23 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D for r in cur.fetchall(): writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"]) - # Activity - cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) - for r in cur.fetchall(): - writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) + # Activity (Layer-1: gemergte session_metrics in Details) + cur.execute( + "SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) + act_rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, act_rows) + for r in act_rows: + base = f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal" + eav_parts = [] + for m in r.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}") + details = base + (" | " + "; ".join(eav_parts) if eav_parts else "") + writer.writerow(["Training", r["date"], r["activity_type"], details]) output.seek(0) @@ -148,7 +163,9 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= data['nutrition'] = [r2d(r) for r in cur.fetchall()] cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) - data['activity'] = [r2d(r) for r in cur.fetchall()] + data["activity"] = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, data["activity"]) + data["activity"] = serialize_dates(data["activity"]) cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,)) data['insights'] = [r2d(r) for r in cur.fetchall()] @@ -243,6 +260,9 @@ Dieser Export kann in Mitai Jinkendo unter Einstellungen → Import → "Mitai Backup importieren" wieder eingespielt werden. +activity.csv (optional): Spalte session_metrics_json (JSON-Array, Layer-1-merge) +wird beim Standard-Import ignoriert; für Vollständigkeit/externe Tools. + Format-Version 2 (ab v9b): Alle CSV-Dateien sind UTF-8 mit BOM kodiert. Trennzeichen: Semikolon (;) @@ -318,13 +338,41 @@ Datumsformat: YYYY-MM-DD r['fiber'] = None; r['note'] = '' write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created']) - cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,)) + cur.execute( + "SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", + (pid,), + ) rows = [r2d(r) for r in cur.fetchall()] + enrich_sessions_with_metrics(cur, rows) for r in rows: - r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None) - r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None) - r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None) - write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created']) + sm = r.pop("session_metrics", None) or [] + r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str) + r["name"] = r["activity_type"] + r["type"] = r.pop("activity_type", None) + r["kcal"] = r.pop("kcal_active", None) + r["heart_rate_avg"] = r.pop("hr_avg", None) + r["heart_rate_max"] = r.pop("hr_max", None) + r["note"] = r.pop("notes", None) + write_csv( + zf, + "activity.csv", + rows, + [ + "id", + "date", + "name", + "type", + "duration_min", + "kcal", + "heart_rate_avg", + "heart_rate_max", + "distance_km", + "note", + "source", + "created", + "session_metrics_json", + ], + ) # 8. insights/ai_insights.json cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))