Merge pull request 'Erste Version Platzhalter EAV' (#86) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 52s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

Reviewed-on: #86
This commit is contained in:
Lars 2026-04-17 21:52:13 +02:00
commit a62c952097
25 changed files with 4263 additions and 374 deletions

View File

@ -55,6 +55,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
---
@ -114,6 +115,11 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| `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 |
| `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` | Composite-Metriken in EAV (JSONB), Archetypen, CSV-Slots, Layer-1-Expand, Migration/Test-Checkliste |
| `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` | **Zielarchitektur** Aktivität (Spine/EAV/Composites/Import/Layer 12) + **Phasenplan AF** Produktionsreife |
| `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` | Issue #53: Aktivitäts-Platzhalter Layer 1 ↔ 2a (Audit Schritt 1) |
| `ACTIVITY_SCALAR_KANON_TABLE.md` | **Skalar-Kanon** Aktivität (eine Semantik → eine Quelle); Phase A |
| *(Code)* `backend/data_layer/activity_data_canon.py` | **Kanon** activity CSV-Modul vs. EAV-primär; Legacy-Lesefallback |
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
---

View File

@ -0,0 +1,317 @@
# Activity Session Metrics: Composite-Daten (EAV) Umsetzungskonzept
**Stand:** 2026-04-16
**Status:** Normatives Konzept zur nahtlosen Weiterarbeit durch Code-Agenten
**Bezieht sich auf:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (§2.32.4, Phasen DE), `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`, Issue #53 (Layer-1-Prinzip: Auswertungen nur über `data_layer`)
---
## 1. Ziel und Abgrenzung
### 1.1 Ziel
- **Composite-Messgrößen** (strukturierte Werte mit mehreren benannten Slots) werden wie **normale Trainingsparameter** im Katalog geführt, **Kategorie-/Typ-Profilen** zugeordnet und pro Session in der **EAV-Tabelle** persistiert.
- **Persistenz:** ein JSON-Dokument pro Session und `training_parameter_id` (kanonisch **JSONB**), kompatibel mit der bestehenden „eine Zeile pro Parameter“-Semantik.
- **Import:** CSV liefert typischerweise **eine Spalte pro atomarem Slot**; das Mapping verweist auf **`(Parameter-Key, Slot-Key)`** (stabile Strings, nicht Spaltenreihenfolge).
- **Layer 1:** liefert für Consumer weiterhin **eine konsistente API**: Rohdokument **und** optional **aufgelöste Einzelwerte** (flach oder namenspaced), ohne dass Charts/Platzhalter direkt JSON parsen müssen.
### 1.2 Nicht-Ziele (explizit)
- Kein „freies“ JSON-Schema im Admin ohne Archetyp-Bindung (verhindert Datenmüll und nicht validierbare Dokumente).
- Keine Abschwächung bestehender **Skalar-Parameter** (`integer`, `float`, `string`, `boolean`): alle bisherigen Pfade bleiben gültig.
- Kein Ersatz für `activity_log`-**Spine** oder Session-Qualitätsblobs (`evaluation`, …).
### 1.3 Kompatibilitätsgarantie („keine Regression“)
| Bereich | Maßnahme |
|---------|----------|
| DB | Nur **additive** Migrationen; bestehende `CHECK`-Regeln für Skalare bleiben für Zeilen **ohne** Composite erhalten bzw. werden zu einer **Oder-Verknüpfung** erweitert (siehe §4). |
| `training_parameters` | Neuer `data_type`-Wert **`composite`** zusätzlich zu den vier bestehenden; bestehende CHECK-Constraint muss erweitert werden (Migration). |
| `activity_session_metrics` | Skalare Zeilen unverändert; Composite-Zeilen nutzen **`value_json`** (neu), alle `value_*` NULL. |
| Layer 1 | `resolve_activity_attribute_schema`, Merge, Replace: Composite erscheint als **ein** Schema-Eintrag; Lese-/Schreibpfade erweitern, nicht ersetzen. |
| CSV | Bestehende Map-Ziele auf Skalare/Registry unverändert; neue Zielnotation nur für Composites. |
| Admin | tcp/ttp-UI: gleiche Zuordnung wie heute; Zusatzfelder nur bei `data_type === composite`. |
### 1.4 Abgleich mit `functional_concept_composite_data.md` (fachliches Konzept)
Das **fachliche Konzeptpapier** (Composite Scalar/Layer-Trennung) und dieses **Umsetzungskonzept** sind **vereinbar**, wenn die Rollen klar getrennt bleiben:
| Thema | Fachliches Konzept (`functional_concept_composite_data.md`) | Dieses Umsetzungskonzept (technisch) |
|--------|-------------------------------------------------------------|--------------------------------------|
| **Speicher in der DB** | Einheitlicher Store; Composite = `jsonb` mit **kleinem Basisschema** (`v`, `kind`, `domain`, `items`, optional `basis`, `meta`) | `activity_session_metrics.value_json`; CHECK Skalar vs. Composite |
| **Technische Container** | Genau **vier** `kind`-Werte: `group_set`, `distribution_set`, `sequence_set`, `model_set` | Layer-1-Validierung **muss** diese Hülle durchsetzen; kein freies JSON ohne `kind`/`v`/`items` |
| **„Archetypen“** | **Fachliche** Ausprägungen werden in **Layer 2a** aus L1-Objekten abgeleitet | Benannte **Preset-/Validierungsprofile** im Code (z.B. Zonenverteilung HF) sind **kein** zweites Persistenz-Schema: sie legen fest, *welches* der vier `kind`-Muster, *welches* `domain`, *welche* Item-Keys/Typen erlaubt sind — inkl. CSV-Slot-Mapping |
| **Layer 1** | Validiert, minimal normalisiert, **keine** Scores/Bewertungen/KI-Texte | Validator + Merge + optional `expand_*` (**technische** Flachstellung für Consumer, z.B. `param.slot` → Skalar) |
| **Layer 2** | Diagramme, Kennzahlen, KI-Platzhalter-**Formulierung** | unverändert; konsumiert L1 (und ggf. L2a) |
**Konsequenz für die Registry:** Statt „8 freie JSON-Archetypen“ implementiert die Code-Registry **Validierungs-Presets**, die alle auf die **vier technischen `kind`-Formen** abbilden. Die Tabelle in §3 beschreibt weiterhin **fachlich benannte MVP-Anker** — technisch übersetzen sie sich in `(kind, domain, Item-Regeln, v)`.
**Konsequenz für Platzhalter:** Roh-JSON aus der DB **nicht** ungefiltert in Prompts; L2b nutzt L1/L2a-Aufbereitung (wie im fachlichen Konzept).
---
## 2. Begriffe
| Begriff | Bedeutung |
|---------|-----------|
| **Archetyp** | Im **Repo versionierte** Strukturvorlage (erlaubte Slots, Typen, Pflichtfelder, Validator, Version). **78** Stück geplant; Erweiterung nur per Code-Release. |
| **Slot** | Benanntes Teilfeld innerhalb des Composite-Dokuments, z.B. `z1_sec`, `z2_sec`, `avg_cadence`. |
| **Parameter-Instanz** | Eine Zeile in `training_parameters` mit `data_type = composite` und Metadaten, **welcher** Archetyp gilt (siehe §5). |
| **Dokument** | Ein JSON-Objekt, das alle Slots abbildet; gespeichert in `activity_session_metrics.value_json`. |
---
## 3. Archetypen-Katalog (Planungsstand) — fachliche Namen → technische `kind`-Presets
Die **konkrete** Slot-Liste und Validierung wird im Code als **Registry** geführt (z.B. `backend/data_layer/activity_composite_archetypes.py`). Jedes Preset **mappt** auf genau eines von **`group_set` | `distribution_set` | `sequence_set` | `model_set`** und erfüllt das **Basisschema** aus `functional_concept_composite_data.md` §7.
Inhaltlich orientiert an `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.4.
**Beispielhafte fachliche MVP-Anker** (8 Kandidaten; im Code als Preset-Key + `kind`/`domain` abbilden):
| `archetype_key` (stabil) | Kurzbeschreibung | Typische Slots (Beispiel) |
|--------------------------|------------------|---------------------------|
| `hr_zone_distribution` | Zeit-/Anteil je HF-Zone | `z1_sec`…`z5_sec` oder `zones[]` |
| `power_zone_distribution` | Leistungszonen | analog |
| `pace_band_profile` | Pace-Bänder / Histogramm | bucket-Struktur |
| `interval_block_summary` | Intervallblöcke aggregiert | `blocks[]` mit Dauer, Ziel, Ist |
| `event_marker_sequence` | Ereignisse mit Zeitstempel | `events[]` |
| `coupling_efficiency_profile` | Kopplungs-/Effizienzmetriken | sportabhängig |
| `model_parameter_profile` | Modell-/Schwellenparameter | key-value-ähnlich, validiert |
| `readiness_recovery_snapshot` | optional: kurzes Multi-Signal-Bundle | nur wenn fachlich gewünscht |
**Regel:** Jeder Archetyp hat `version` (Integer). Validator lehnt Dokumente mit falscher/fehlender Version ab oder migriert definiert (nur wenn spezifiziert).
---
## 4. Datenmodell-Erweiterungen
### 4.1 `training_parameters`
**Migration (additiv):**
1. `CHECK (data_type IN (...))` erweitern um **`composite`**.
2. Optional eigene Spalte **`composite_archetype_key` `VARCHAR(64)`** (NOT NULL wenn `data_type = composite`, sonst NULL) — **oder** ausschließlich in `validation_rules` speichern (siehe unten).
**Empfehlung:** Spalte `composite_archetype_key` + `composite_archetype_version INT` für einfache Admin-Queries und klare Semantik; `validation_rules` für archetyp-spezifische Feinheiten (z.B. erlaubte Zonenanzahl).
**Konsistenz-Constraint (DB oder App):**
- Wenn `data_type = composite`: `composite_archetype_key` gesetzt, `source_field` typischerweise **NULL** (kein `activity_log`-Skalar-Shadowing).
- `unit` am Parameter: optional für „Anzeige-Einheit“ des Gesamtwerts oder leer; Slots haben Einheiten im Archetyp oder in Slot-Metadaten.
### 4.2 `activity_session_metrics`
**Migration (additiv):**
```text
value_json JSONB NULL
```
**CHECK-Constraint ersetzen/erweitern** (Konzept):
- **Modus Skalar:** genau eine der Spalten `value_num`, `value_int`, `value_text`, `value_bool` ist NOT NULL; `value_json` IS NULL.
- **Modus Composite:** `value_json` IS NOT NULL; alle vier Skalar-Spalten IS NULL.
Damit bleibt die bestehende Semantik „eine Zeile = ein Parameter“ erhalten.
**Kommentar:** Tabelle trägt weiterhin „EAV“; Composites sind **keine** zusätzlichen Zeilen pro Slot.
### 4.3 Profil-Zuordnung (tcp / ttp)
**Keine** Tabellenänderung: `training_category_parameter` und `training_type_parameter` verweisen weiter nur auf `training_parameter_id`. Composite-Parameter verhalten sich wie Skalare in Bezug auf **Zuordnung**, **sort_order**, **required**, **ui_group**.
**`required`:** bedeutet „Dokument muss nach Validator vollständig sein“, nicht „jede CSV-Spalte muss in jeder Zeile vorkommen“.
---
## 5. Metadaten pro Composite-Parameter
Minimal in der DB (Beispiel):
| Feld | Zweck |
|------|--------|
| `data_type` | `composite` |
| `composite_archetype_key` | Verweis auf Code-Registry |
| `composite_archetype_version` | Schema-Version |
| `validation_rules` | optional: Overrides (z.B. `max_zones`, sport-spezifisch) — nur was der Validator explizit auswertet |
**Admin-API:** bestehende Endpoints erweitern (Payload-Validierung): bei `composite` müssen Archetyp + Version gesetzt sein und in der **Registry** existieren.
---
## 6. Layer 1 Kontrakt (`activity_session_metrics.py` + Helfer)
### 6.1 Schema-Auflösung
`resolve_activity_attribute_schema` liefert pro Composite **einen** Eintrag wie bei Skalaren, mit:
- `data_type: "composite"`
- `composite_archetype_key`, `composite_archetype_version` (aus DB oder Join)
- ggf. `composite_slot_catalog`: **nur wenn** für Admin/UI gewünscht — alternativ separater Endpoint `GET .../composite-archetypes` (read-only) aus Registry, um Bundle-Größe klein zu halten.
### 6.2 Lesen / Merge
- `fetch_activity_session_metrics`: SELECT inkl. `value_json`.
- `merge_column_backed_and_eav_metrics`: Composites **nur** aus EAV (`value_json`), kein `activity_log`-Shadowing (außer später explizit im Kanon — Standard: nein).
- Ausgabe in `metrics`-Liste: ein Eintrag pro Parameter mit z.B.
`value: { "_composite": true, "document": { ... } }` **oder** kanonisch getrennt: `value_document` + `value` null — **festlegen beim Implementieren** und in API-Doku halten; Empfehlung: **`value` = deserialisiertes Objekt (dict)** für Composites, damit Frontend dieselbe Struktur wie Speicher hat.
### 6.3 „Einzelwerte für Layer 1 / Issue 53“
Neue **pure** Funktion (kein SQL im Router), z.B.:
```text
expand_composite_metrics_for_session(
schema: list[dict],
metrics: list[dict],
) -> dict[str, Any]
```
- Input: effektives Schema + gemergte Metriken.
- Output: flaches Dict **`slot_path → typisierter Wert`**, z.B.
`hr_zones.z1_sec → 1200`, oder namespaced Keys `training_param_key.slot_key` zur Kollisionssicherheit.
- Nutzung: `activity_metrics`, Chart-Builder, später Platzhalter-Registry (`data_layer_function`), **ohne** JSON-Parsing in Layer 2.
**Wichtig:** Skalare Parameter erscheinen im expandierten Dict mit ihrem `parameter_key` wie bisher (kein Breaking Change für Consumer, die nur Skalare erwarten).
### 6.4 Validierung / Schreiben
- **`replace_activity_session_metrics`:** Payload-Item für Composite: `value` ist **Objekt** (dict) oder JSON-String — Server normalisiert zu dict, validiert mit Archetyp-Validator, speichert als `value_json`.
- **`upsert_session_metrics_from_csv_mapped`:** siehe §7 (Zusammenbau aus Partial-Updates pro Zeile).
**Pflicht:** Keine Teil-Updates in DB, die ein halbes Dokument hinterlassen, ohne Validierung — außer explizit als „Draft“-Modus spezifiziert (nicht Teil dieses Konzepts).
---
## 7. CSV / Universal Import
### 7.1 Map-Ziel-Notation
Stabiles Muster (Vorschlag, im Import-Modul zentral parsen):
```text
"<parameter_key>.<slot_key>"
```
Beispiel: `my_hr_zones.z1_sec` → nach Import-Zusammenfügung in den Parameter `my_hr_zones` unter Slot `z1_sec`.
**Alternative:** explizites Präfix `composite:` in der Vorlage — nur nötig, wenn Kollisionen mit normalen Keys befürchtet werden; sonst Punkt-Notation reicht.
### 7.2 Executor-Flow (Konzept)
1. `build_row_after_mapping` liefert flache Keys inkl. `param.slot`.
2. Nach Schreiben von `activity_log` / Skalar-EAV: **Composite-Accumulator** pro `activity_log_id` und `parameter_key`:
- Sammelt alle Slot-Werte aus der Zeile.
3. Vor Commit der Zeile (oder am Ende der Datei — **pro Zeile empfohlen**, damit SAVEPOINT pro Row funktioniert):
- Dokument aus Slots bauen → Validator → Upsert `activity_session_metrics` mit `value_json`.
**Teilbefüllung:** Validator entscheidet (Archetyp: optional vs. required Slots). CSV darf nur Teilmengen liefern, wenn Archetyp erlaubt.
### 7.3 Typkonvertierung
Pro **Slot** im Archetyp: definierter skalarer Typ (`float`, `int`, …). Converter wie bei Skalaren (Executor / zentrale Converter), **keine** Parallel-Logik in Routern.
---
## 8. Admin-UI / Mapping-UX
### 8.1 Parameter anlegen
- Auswahl **Datentyp „Composite“** → Dropdown **Archetyp** (aus Registry-API), Version readonly oder wählbar gemäß Policy.
- Rest wie Skalar: Name, Kategorie (`training_parameters.category`), Aktiv-Flag.
### 8.2 Profil zuordnen
Unverändert: Kategorie-/Typ-Matrix wie heute.
### 8.3 Universal-CSV-Vorlage
- Mapping-Ziele: neben bisherigen Keys **Slot-Ziele** `parameter_key.slot_key`.
- UI-Gruppierung: optisch **Composite-Block** (wie in `ACTIVITY_PRODUCTION_ARCHITECTURE` §2.5 angedeutet), um Verwechslung mit Spine-Spalten zu vermeiden.
---
## 9. API-Oberflächen (Erweiterungen)
| Bereich | Änderung |
|---------|-----------|
| `GET /api/activity/{id}` | `metrics` enthält Composite-Werte als Objekt; `schema` kennzeichnet `data_type: composite`. |
| `PUT /api/activity/{id}/metrics` | Eintrag `{ parameter_key, value: { ... } }` für Composites. |
| Admin `training-parameters` | Create/Update mit Composite-Feldern. |
| Optional | `GET /api/admin/composite-archetypes` | Registry export für UI (Keys, Slot-Liste, Version). |
**Rückwärtskompatibilität:** Clients, die nur Skalare senden, unverändert.
---
## 10. Frontend (Kurz)
- `ActivityPage` / Session-Metrik-Editor: für `data_type === composite` **strukturierte Teilfelder** aus Slot-Katalog rendern (oder JSON-Editor nur als Entwickler-Fallback — Produkt: strukturierte Felder).
- Sortierung/Gruppierung: bestehende `param_category` / `ui_group` / `sort_order` gelten unverändert.
---
## 11. Tests (pytest)
| Test | Beschreibung |
|------|----------------|
| Archetyp-Validator | gültige / ungültige Dokumente je Version |
| DB-Constraint | Skalar vs. Composite Ausschluss |
| `expand_composite_metrics_for_session` | flache Keys, Kollisionen |
| CSV-Zusammenbau | mehrere Spalten → ein `value_json` |
| Regression | bestehende `test_activity_session_metrics.py` unverändert grün halten |
---
## 12. Rollout-Phasen (operativ)
Stimmt mit `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` überein:
1. **Phase D MVP:** ein Preset (z.B. HF-Zonen → `distribution_set`, `domain: heart_rate`), Migration `value_json` + `composite` data_type, Validator gegen Basisschema §7, Import 35 Spalten → `items`, GET/PUT, minimale Admin-Anbindung.
2. **Phase E:** weitere Presets / `kind`-Varianten, Mapping-UX, `expand_*` für ausgewählte Layer-1-Consumer.
3. **Phase F:** Observability, Performance, Doku, Gitea-Issues schließen.
### 12.1 Empfohlene Reihenfolge: Skalar-Pipeline vs. Composite-Speicherung
**Frage:** Zuerst Skalar-EAV vollständig bis Platzhalter/Orchestrator abschließen, oder zuerst Composite-Speicherung?
| Option | Vorteil | Risiko |
|--------|---------|--------|
| **A: Nur Skalar zuerst** (Kanon, L1-Härtung, Platzhalter aus EAV/L1) | Eine klare, end-to-end **Referenzpipeline**; weniger gleichzeitige Variablen | Composite-Datenstrome verzögern sich |
| **B: Composite-Speicher zuerst** | JSON landet früh in der DB | Platzhalter/Charts nutzen noch **alte** Pfade → **zwei Wahrheiten** (Detail-API vs. KI) bis L1 vereinheitlicht ist |
| **C (Empfehlung): Skalar L1 + Platzhalter-Orchestrierung *vor* Composite-MVP**, oder **eng parallel** mit gemeinsamem L1-Einstieg | `get_activity_session_logical_unit` / `activity_metrics` werden **kanonisch**; Platzhalter lesen **dieselbe** Schicht; Composite wird **additiv** (`value_json` + Validator + später `expand_*`) | Erfordert kurze Planungsdisziplin: Composite-MVP **ohne** sofort alle KI-Platzhalter |
**Konkrete Empfehlung**
1. **`ACTIVITY_PRODUCTION` Phase AB** nicht überspringen: Kanon „eine Semantik / eine Quelle“ + alle relevanten Consumer über **Layer 1** (mind. Session-Detail, Listen-Anreicherung, erste Platzhalter-Pfade für **Skalare**).
2. **Dann Phase D (Composite-MVP):** Migration + Speichern/Lesen mit **Basisschema** (`kind`/`items`/…); L1 liefert dasselbe API-Objekt wie Skalare, nur `value` als strukturiertes Dokument.
3. **Platzhalter für Composite:** erst **nach** L1 liefert stabil `value_json` **und** optional `expand_composite_metrics_*` — ein Orchestrator-Endpoint bzw. Resolver-Aufruf, der **eine** L1-Funktion nutzt, vermeidet doppelte Logik für Skalar vs. Composite.
**Kurz:** Composite **persistieren** kann kurz nach stabiler **Skalar-Lese-/Merge-API** folgen; **KI/Platzhalter für Composite** sinnvoll **gemeinsam** mit der erweiterten L1-Ausgabe bauen, nicht gegen eine noch nicht vereinheitlichte Skalar-Pipeline.
---
## 13. Checkliste für den nächsten Agenten
- [ ] Migration: `value_json`, erweiterte CHECKs, `training_parameters.data_type` + ggf. `composite_archetype_*` Spalten.
- [ ] Registry-Modul: Archetypen + Versionen + Slot-Metadaten + Validator-Einstieg.
- [ ] `activity_session_metrics.py`: Fetch/Merge/Replace/Upsert-Integration; keine Regression für Skalare.
- [ ] Optional: `expand_composite_metrics_for_session` + erste Nutzung in einem Layer-1-Consumer (Tests).
- [ ] CSV: Parser für `parameter_key.slot_key`, Row-Accumulator, Fehler melden wie bestehender Import.
- [ ] Admin-API + UI: Composite anlegen, tcp/ttp unverändert nutzbar.
- [ ] Doku: dieses Dokument mit **festgelegter** JSON-Beispielstruktur pro MVP-Archetyp ergänzen.
---
## 14. Referenzen
- `functional_concept_composite_data.md` **fachliches** Schichtenmodell, vier technische `kind`-Container, Basisschema JSON
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` Zielbild, Phasen AF
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` Ist-Layer-1, APIs
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` Executor, Vorlagen
- Migration `054_activity_session_metrics_eav.sql` Ist-Constraint Skalar
- Migration `013_training_parameters.sql` Ist-`data_type`-Enum
---
**Version:** 1.1 · Abgleich mit fachlichem Konzept (§1.4, §3, §12.1); MVP auf `distribution_set` o. ä. konkretisieren.

View File

@ -0,0 +1,70 @@
# Aktivität: Layer-2a-Platzhalter — Audit Schritt 1 (Issue #53)
**Stand:** 2026-04-16
**Bezug:** [Issue #53 — Multi-Layer Architecture](../../../docs/issues/issue-53-phase-0c-multi-layer-architecture.md): Layer 1 = strukturierte Daten, Layer 2a = KI-Formatierung (keine parallele Domänen-Logik im Resolver).
**Ziel dieses Dokuments:** Jeder Aktivitäts-Platzhalter hat genau eine **Layer1Quelle** (`data_layer/activity_metrics.py`); `placeholder_resolver.py` formatiert oder serialisiert nur noch.
---
## 1. Ergebnisübersicht
| Kategorie | Anzahl | Resolver-SQL für Aktivität? |
|-----------|--------|------------------------------|
| Gebündelt in `PLACEHOLDER_MAP` (Training/Aktivität) | 20 | **Nein** |
| Abweichungen / offene Punkte | 0 | — |
**Hinweis:** `{{rest_days_count}}` steht in der Karte unter „Schlaf & Erholung“ und nutzt `recovery_metrics.get_rest_days_data` — nicht in dieser Tabelle.
---
## 2. Platzhalter → Layer 1 → Layer 2a
| Key | Layer 1 (`activity_metrics`) | Layer 2a (`placeholder_resolver`) | Bemerkung |
|-----|------------------------------|-------------------------------------|-----------|
| `activity_summary` | `get_activity_summary_data` | `get_activity_summary` | String-Zusammenfassung |
| `activity_detail` | `get_activity_detail_data` (+ `enrich_sessions_with_metrics`) | `get_activity_detail` | Dynamische `session_metrics[]` pro Zeile (Profil/EAV) |
| `trainingstyp_verteilung` | `get_training_type_distribution_data` | `get_trainingstyp_verteilung` | Ausgabe: Top-3-Text (kein JSON); Registry 2026-04 an Ist angeglichen |
| `training_minutes_week` | `calculate_training_minutes_week` | `_safe_int` | |
| `training_frequency_7d` | `calculate_training_frequency_7d` | `_safe_int` | |
| `quality_sessions_pct` | `calculate_quality_sessions_pct` | `_safe_int` | |
| `proxy_internal_load_7d` | `calculate_proxy_internal_load_7d` | `_safe_int` | |
| `monotony_score` | `calculate_monotony_score` | `_safe_float` | |
| `strain_score` | `calculate_strain_score` | `_safe_int` | |
| `rest_day_compliance` | `calculate_rest_day_compliance` | `_safe_int` | |
| `ability_balance_strength` | `calculate_ability_balance_strength` | `_safe_int` | abilities in `activity_log` |
| `ability_balance_endurance` | `calculate_ability_balance_endurance` | `_safe_int` | |
| `ability_balance_mental` | `calculate_ability_balance_mental` | `_safe_int` | |
| `ability_balance_coordination` | `calculate_ability_balance_coordination` | `_safe_int` | |
| `ability_balance_mobility` | `calculate_ability_balance_mobility` | `_safe_int` | |
| `vo2max_trend_28d` | `calculate_vo2max_trend_28d` | `_safe_float` | |
| `activity_score` | `calculate_activity_score` | `_safe_int` | |
| `training_frequency_by_type_md` | `get_training_frequency_by_type_data` | `get_training_frequency_by_type_md` | Markdown-Tabelle |
| `training_inter_session_gap_md` | `get_training_inter_session_gap_data` | `get_training_inter_session_gap_md` | Markdown-Text |
| `training_sessions_recent_json` | `get_training_sessions_recent_weeks_data` (+ `enrich_sessions_with_metrics`) | `_safe_json('training_sessions_recent_json')` | JSON inkl. `session_metrics[]` pro Session |
---
## 3. Schichten-Disziplin (Checkliste)
- [x] Kein `SELECT` auf `activity_log` / `activity_session_metrics` in den **Layer2a**-Funktionen oben — nur Aufrufe in Layer 1 bzw. `_safe_*`-Wrapper.
- [x] `get_activity_detail` / `get_training_sessions_recent_json` liefern EAV nur über **bereits gemergte** `session_metrics` (Merge-Kanon: `activity_log` vor EAV).
- [x] Registry-Metadaten: `data_layer_module` / `data_layer_function` pro Key in `placeholder_registrations/activity_metrics.py` und `activity_session_insights.py`.
- [x] Korrektur Registry: `activity_summary.resolver_function` = `get_activity_summary` (war veraltet: `_format_activity_summary`).
---
## 4. Nächste Schritte (Roadmap)
2. ~~**Registry-Texte:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` (tcp/ttp) und Merge-Kanon — **erledigt** (`activity_detail`, `training_sessions_recent_json`); dazu **`trainingstyp_verteilung`**-Metadaten von veraltetem „JSON/Resolver-SQL“ auf Ist (**Layer 1 + Top-3-Text**) korrigiert.~~
3. **History / Layer 2b:** EAV-Zeitreihen nicht über Platzhalter, sondern dedizierte Layer1-/Chart-Pfade.
4. **Optional:** Gitea-Issue „Activity Layer 2a“ bei Änderungen an `activity_metrics` pflegen.
---
## 5. Referenzen
- `backend/placeholder_resolver.py``PLACEHOLDER_MAP` (Training/Aktivität)
- `backend/placeholder_registrations/activity_metrics.py`
- `backend/placeholder_registrations/activity_session_insights.py`
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §2.1a (Navigation Read vs. Berechnen)

View File

@ -0,0 +1,215 @@
# Aktivität: Zielarchitektur & Phasenplan (Produktionsreife)
**Stand:** 2026-04-16
**Status:** Normative Zielrichtung für `activity_log`, EAV, Composites, Import, Layer 1/2.
**Ergänzt:** `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` (Ist-Modell, APIs, Tests).
**Phase A:** abgeschlossen — Kanon-Tabelle [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**Phase B:** in Arbeit — Consumer-Audit und Lesepfad-Härtung (siehe §4 Phase B).
---
## 1. Leitprinzipien
| Prinzip | Bedeutung |
|---------|-----------|
| **Layer 1 = Single Source of Truth** | Alle Auswertungen (Charts, Scores, strukturierte Platzhalter) lesen **nur** über `data_layer` (kanonische Funktionen). Keine parallele SQL-Logik in Routern oder im Placeholder-Resolver für Aktivität. |
| **Eine semantische Größe, eine kanonische Quelle** | Kein Dauer-Sync derselben Bedeutung in `activity_log`-Spalte **und** EAV. Übergang: dokumentierte Abschaltung, nicht implizites Driften. |
| **Spine vs. Parameter** | `activity_log` trägt Identität, Zeit, Typ, Notizen, Audit + **heiße** universelle Skalare (siehe §2.2). Alles Typ-/Admin-Dynamische über EAV. |
| **Composites = Archetyp im Code, Konfiguration in der DB** | Struktur (7+2 Archetypen) und Validierung **versioniert im Repo**; Admin **wählt** Archetyp, **benennt** Slots, **bindet** Sportarten, **mappt** CSV → `(parameter_id, slot_key)`. Kein freies JSON-Schema im Admin. |
| **Import explizit** | Jede CSV-Spalte hat ein klares Ziel: Spine-Spalte, skalarer Parameter oder **Slot** eines Composite-Parameters. Typkonvertierung zentral (Executor / Converter), nicht verteilt. |
---
## 2. Zielarchitektur (Gesamtbild)
### 2.1 Schichtenmodell
```
[CSV / UI / API Write]
Orchestrator & Router (Auth, Transaktionen, Feature-Checks)
Persistenz: activity_log (Spine + heiße Skalare) + activity_session_metrics (EAV)
Layer 1: data_layer (activity_session_metrics.py, activity_metrics.py, …)
Layer 2a/2b: Platzhalter-Resolver (Formatierung), Chart-Endpoints (Chart.js-Shapes)
KI / UI / Export
```
- **Orchestrator:** Schreibpfad, Konsistenz nach Write (kein zweites „Lesen der Wahrheit“ neben Layer 1; optional nur Post-Write-Hooks).
- **Resolver:** für Aktivität **kein** direkter DB-Zugriff; nur Aufruf von Layer 1.
### 2.1a Navigationsregel: wo nachsehen (ohne Datei-Zwang)
Die **physische** Aufteilung ist dreigeteilt: **`activity_log`** (Spine + heiße Spalten), **EAV-Skalare** (`activity_session_metrics` + numerische/textuelle `value_*`), **EAV-Composites** (ein Parameter, Nutzlast z.B. JSON/JSONB im EAV-Datensatz). **Fachlich** soll nach außen **eine homogene Session-Sicht** entstehen — Consumer sollen nicht selbst entscheiden, aus welcher Tabelle/Welche Form ein Wert kommt.
| Thema | Wo nachsehen (Ist; Ziel: Schnittstelle stabil, Datei optional splittbar) |
|--------|--------------------------------------------------------------------------|
| **Homogene Session lesen** (Merge Spalte + EAV-Skalare + später Composite-Payload) | `data_layer/activity_session_metrics.py` — u.a. `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics` |
| **Schreiben / Import / API-Persistenz** | `data_layer/activity_persistence_orchestrator.py` (+ Router) |
| **Berechnungen, Aggregationen, Scores** über viele Sessions oder Zeitfenster | `data_layer/activity_metrics.py` — arbeitet auf der **vereinheitlichten** Session-Datenlage (über die Read-Funktionen oben), nicht durch paralleles Mergen der drei Quellen im Caller |
**Hinweis:** Orchestrator und Read-Merge **müssen nicht** in derselben Datei stehen. Entscheidend ist, dass es **genau eine dokumentierte Read-Fassade** für „Session inkl. aller effektiven Metriken“ gibt und Layer1Berechnungen **nur** diese Fassade (oder deren Ergebnisstrukturen) nutzen. Eine spätere Umbenennung oder Auslagerung in z.B. `activity_read_gateway.py` ändert die Rolle nicht — nur der **eine Einstieg** muss in dieser Doku und im Code auffindbar bleiben.
### 2.2 `activity_log` (Spine + heiße Skalare)
**Maschinenlesbarer Kanon:** `backend/data_layer/activity_data_canon.py` (`ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`, `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS`, Legacy-Lesefallback für EAV-primäre Parameter).
**Immer (fachlich minimal + listenfähig):** `id`, `profile_id`, Kalender-/Zeitfenster (`date`, `started_at`/`ended_at`, ggf. `start_time`/`end_time` bis Konsolidierung), `duration_min`, `training_type_id` (+ ggf. denormalisierte Kategorie), Legacy `activity_type`, `notes`, `source`, `created`.
**Heiße Skalare (CSV-Modul + `source_field` nach Migration 057):** u.a. `kcal_active`, `kcal_resting`, `distance_km`, `hr_avg`/`hr_max` (Parameter `avg_hr`/`max_hr`), `duration_min`, `rpe` für Listen und Standard-Aggregate ohne EAV-Join.
**EAV-primär (erweiterte Metriken):** z.B. Kadenz, Pace, Leistung, Höhe, Umgebung — `training_parameters.source_field` = NULL; Import schreibt EAV; bei leerem EAV optional Lesefallback auf bestehende `activity_log`-Spalte (Migration 057 + Merge-Logik).
**Session-Qualität / Auswertungsblob:** z.B. `evaluation`, `quality_label`, `overall_score` **kein** EAV-Parameter-Raster; semantisch „Ergebnis der Einheit“.
**Nicht dauerhaft doppelt:** dieselbe Semantik nicht parallel pflegen; siehe entfallener Spalte→EAV-Schreib-Sync, Lesepfad `merge_column_backed_and_eav_metrics`.
### 2.3 EAV (`activity_session_metrics`)
- **Skalare:** ein `training_parameter`, genau eine `value_*`-Spalte (wie heute).
- **Composites:** ein `training_parameter` pro Composite-Instanz, **ein** gespeichertes Dokument pro Session (serialisiert z.B. in `value_text` als JSON **oder** künftig dedizierte JSONB-Spalte technische Entscheidung in eigener Migration, Vertrag im Archetyp).
- **Merge-/Schema-Logik:** weiterhin zentral in `activity_session_metrics.py` (effektives Schema aus Kategorie + Typ-Overrides).
### 2.4 Composite-Metamodell (Ziel)
**Archetypen (Code, begrenzte Menge):** u.a. Band-/Zonenverteilung, Sequenz-/Übergangsprofil, Intervallblock-, Ereignis-/Aktions-, Kopplungs-/Effizienz-, Modellparameter-Profil; optional Technik-/Zyklus-, Readiness-/Recovery-Profil.
**Pro Archetyp:** feste strukturelle Regeln (erlaubte Slots, Typen, Pflicht/Optional), Validator + Version.
**In der DB (Admin):** Zuordnung „Parameter X hat Archetyp A“, Slot-Labels (DE/EN), Einheiten, Aktivierung pro Sportart/Kategorie, Sortierung.
**Import:** CSV-Spalten → `(training_parameter_id, slot_key)` mit stabilen Keys (`z1_sec`, …), nie nur „Spaltenreihenfolge“.
### 2.5 Universal CSV & Admin
- Vorlagen: Mapping inkl. **Composite-Slots** und Typkonvertierung (vollständige Matrix Ziel).
- UI: Trennung **Kern activity_log** vs. **Parameter/EAV** vs. **Composite-Blöcke** (optisch/UX), um Doppel-Tabellen-Chaos zu vermeiden.
### 2.6 Layer 2 (Platzhalter & Diagramme)
- Datenbezug **nur** Layer 1.
- Registry-Einträge: `data_layer_module` / `data_layer_function` pflegen; Composite-Auswertung ggf. über Hilfsfunktionen, die JSON → normierte Struktur für Prompts/Charts liefern.
---
## 3. Ist → Soll (Kurz)
| Bereich | Ist (typisch) | Soll |
|---------|----------------|------|
| Schreibpfad | Teilweise Doppelhaltung Spalte ↔ EAV, Sync-Hooks | Kanon + gezielte Abschaltung; eine Quelle pro Semantik |
| Lesepfad | Layer 1 wächst; Legacy-Spalten noch relevant | `get_activity_session_logical_unit` / `activity_metrics` als alleinige Wahrheit für Consumer |
| Composites | Noch nicht im Einklang mit EAV-Metamodell | Archetypen + Slot-Admin + ein Dokument pro Parameter/Session |
| Import | Mapping teilweise; Typkonvertierung lückenhaft | Vollständige Konvertierung + Composite-Zusammenbau |
| Resolver | Aktivität sauber über Layer 1 | Profil/Focus ggf. später ebenfalls aus Layer 1 |
---
## 4. Vorgehensmodell (Phasen)
Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel (z.B. UI-Polish) laufen, wenn der Kanon steht.
### Phase A Kanon & Abschaltplan (Grundlage) ✅
**Inhalt:** Schriftliche **Kanon-Tabelle**: pro Messgröße genau eine Quelle (`activity_log` | `eav_scalar` | `eav_composite` | `session_quality`). Liste der Keys, für die **Sync/Spiegelung** endet.
**Definition of Done:** Review im Team; Referenz in diesem Dokument oder Verweis auf Gitea-Kommentar; keine Code-Änderung zwingend.
**Erledigt (2026-04-16):** [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md) — eine Semantik pro Zeile, verlinkt mit `activity_data_canon.py` und Merge-Logik.
---
### Phase B Lesepfad härten (Layer 1) 🔄
**Inhalt:** Sicherstellen, dass **alle** relevanten Consumer (mind. `activity_metrics` für Platzhalter/Charts, Activity-Detail-API) dieselbe Merge-/Fallback-Logik nutzen; Legacy-Spalten nur noch als dokumentierter Fallback bis Enddatum.
**Definition of Done:** Kurze Audit-Liste „Router/Resolver greifen nicht an Aktivität vorbei“; Tests oder manuelle Stichprobe für Detail + ein Chart + 2 Platzhalter.
**Abhängigkeit:** Phase A für „welche Spalten noch Fallback sind“.
**Audit-Stand (2026-04-16, ergänzt Export):**
| Consumer | Nutzt Layer-1-Merge (`enrich_sessions_with_metrics` / `get_activity_session_logical_unit`) | Anmerkung |
|----------|---------------------------------------------------------------------------------------------|-----------|
| `GET /api/activity/{eid}` | ✅ `get_activity_session_logical_unit` | Referenz-Detail |
| `GET /api/activity` (Liste) | ✅ seit 2026-04-16 `enrich_sessions_with_metrics` auf jeder Listen-Antwort | vorher nur Roh-Spalten |
| `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` (A1A8) | 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 |
---
### Phase C Schreibpfad entschlacken
**Inhalt:** Orchestrierung/CSV: kein Schreiben derselben Semantik an zwei Orten; `sync_column_backed_session_metrics` (o. ä.) **stufig abschalten** oder auf Notfall-Flag; Import schreibt gemäß Kanon.
**Definition of Done:** Deploy auf Prod mit Monitoring; Stichprobe Import + manuelle Bearbeitung; keine Regression in Listenansicht.
**Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen).
**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**.
| Prüfpunkt | Ergebnis |
|-----------|----------|
| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. |
| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. |
| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. |
| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. |
| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. |
**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben.
---
### Phase D Composite MVP
**Inhalt:** Ein Archetyp end-to-end (z.B. **Band-/Zonenverteilung**): Code-Validator, DB-Binding (Parameter + Slots), Admin-UI minimal, Import **5 Spalten → ein JSON-Dokument** mit festen Keys, Layer-1-Read (Roh + optional `expand_*`).
**Definition of Done:** Eine Sportart/Kategorie befüllbar; Dokumentation des JSON-Vertrags im Repo; pytest für Validator/Zusammenbau wo möglich.
**Abhängigkeit:** Phase A (Kanon „Composites nur als Dokument, nicht doppelt in Spalten“).
---
### Phase E Composite-Ausbau & Typkonvertierung Import
**Inhalt:** Weitere Archetypen nach Priorität; Universal-CSV **vollständige** Typkonvertierung für alle gemappten Ziele; Dialog-/Mapping-Konzept (Kern vs. Parameter vs. Composite).
**Definition of Done:** Matrix „Zieltyp × Converter“ gepflegt; Admin-Flow reviewt.
---
### Phase F Produktionshärtung
**Inhalt:** Performance-Indizes bei Bedarf; Observability (Import-Fehler, Validierungs-Fails); Resolver/Profil optional komplett ohne `get_db` für domänische Daten; Doku + Gitea-Issues geschlossen/aktualisiert.
---
## 5. Was zuerst?
**Erledigt:** Phase A — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
**Aktuell:** Phase B fortsetzen (weitere Consumer prüfen: Export, Import-Vorschau, ggf. zukünftige Chart-Metriken aus EAV), dann **Phase C** (Schreibpfad), dann **Phase D** (Composite-MVP).
---
## 6. Referenzen
- `ACTIVITY_SCALAR_KANON_TABLE.md` **Skalar-Kanon** (Phase A)
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` Tabellen, APIs, Tests, Backfill-Hinweise
- `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` Composite-EAV (JSONB), Archetypen, Import-Slots, Layer-1-Expand, Migrations- und Testplan
- `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` Executor, Vorlagen, Typen
- `PLACEHOLDER_REGISTRY_FRAMEWORK.md` Layer-2-Registrierung
- `functional/DATA_ARCHITECTURE.md` fachliche Datenarchitektur (Querschnitt)
---
**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen.

View File

@ -0,0 +1,95 @@
# Aktivität: Skalar-Kanon (eine Semantik → eine Quelle)
**Stand:** 2026-04-16
**Normativer Code:** `backend/data_layer/activity_data_canon.py`
**Kontext:** `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (Phase A abgeschlossen)
---
## 1. Spine & Identität (`activity_log`, nicht EAV)
Diese Felder sind **keine** `training_parameters`-Skalare. Sie gehören zur Session-Zeile.
| Semantik | DB / API | Kanonische Quelle | Lesefallback | Sync Spalte↔EAV |
|----------|----------|-------------------|--------------|-----------------|
| Primärschlüssel | `activity_log.id` | `activity_log` | — | — |
| Profil | `profile_id` | `activity_log` | — | — |
| Kalendertag | `date` | `activity_log` | — | — |
| Start / Ende (Zeit) | `start_time`, `end_time`, `started_at`, `ended_at` | `activity_log` | — | — |
| Trainingsart (Freitext/Legacy) | `activity_type` | `activity_log` | — | — |
| Referenz Trainingstyp | `training_type_id`, `training_category`, … | `activity_log` (+ `training_types`) | — | — |
| Notiz | `notes` | `activity_log` | — | — |
| Quelle / Import | `source`, `created`, … | `activity_log` | — | — |
| Session-Auswertung | `evaluation`, `quality_label`, `overall_score`, … | `activity_log` (Blob/Ergebnis) | — | Kein EAV-Raster |
---
## 2. Kernfelder CSV-Modul `activity` (= „heiße“ Skalare)
Abgeleitet aus `csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields` — maschinenlesbar über `ACTIVITY_MODULE_REGISTRY_FIELD_KEYS` in `activity_data_canon.py`.
| Semantik | Key (Registry/API) | Kanonische Quelle | Lesefallback | Bemerkung |
|----------|-------------------|-------------------|--------------|-----------|
| Dauer | `duration_min` | **`activity_log`** | — | Aggregates, Listen |
| Aktive Energie | `kcal_active` | **`activity_log`** | — | |
| Ruhe-Energie | `kcal_resting` | **`activity_log`** | — | |
| Distanz | `distance_km` | **`activity_log`** | — | |
| Ø HF | `hr_avg` (Parameter oft `avg_hr` in EAV-Schema) | **`activity_log`** | EAV nur wenn `source_field` / Profil-Schema | `merge_column_backed_and_eav_metrics`: Spalte schlägt EAV |
| Max-HF | `hr_max` | **`activity_log`** | analog | |
| RPE | `rpe` | **`activity_log`** | analog | |
Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mappen, sofern nicht ausdrücklich ein EAV-primärer Parameter (§3) gewählt ist.
---
## 3. EAV-primäre Parameter (erweiterte Skalare)
`ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**.
| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) |
|-------------------------------------------|------------------------------|------------------------|
| `min_hr` | `hr_min` | **EAV** |
| `pace_min_per_km` | `pace_min_per_km` | **EAV** |
| `cadence` | `cadence` | **EAV** |
| `avg_power` | `avg_power` | **EAV** |
| `elevation_gain` | `elevation_gain` | **EAV** |
| `temperature_celsius` | `temperature_celsius` | **EAV** |
| `humidity_percent` | `humidity_percent` | **EAV** |
| `avg_hr_percent` | `avg_hr_percent` | **EAV** |
| `kcal_per_km` | `kcal_per_km` | **EAV** |
**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist.
---
## 4. Profil-/Typ-dynamische Skalare (EAV, nicht in Registry-Kernliste)
| Semantik | Kanonische Quelle | Lesefallback |
|----------|-------------------|--------------|
| Admin-definierte Parameter (Attributprofil Kategorie/Typ) | **`activity_session_metrics`** + `training_parameters` | — |
| Parameter mit `source_field` → Spalte | **`activity_log`** (Spalte) | EAV ergänzend; Leseregel: Spalte bevorzugt (kein veraltetes EAV) |
---
## 5. Composites (Zielbild, noch nicht Kanon-Zeile pro Slot)
| Semantik | Kanonische Quelle (Ziel) |
|----------|---------------------------|
| Strukturierte Composite-Dokumente (z.B. Zonen/Bänder) | **EAV** ein Dokument pro Parameter/Session (siehe `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`) |
Kein dauerhaftes Spiegeln derselben Semantik in `activity_log`-Spalten.
---
## 6. Sync & Übergang
- **Kein** automatischer Dauer-Sync „Spalte → EAV“ für dieselbe Semantik; Lesepfad vereinheitlicht die Sicht (`merge_column_backed_and_eav_metrics`).
- Optionale **Backfill**-Migration/Skript (idempotent) nur nach fachlicher Freigabe — siehe EAV-Agent-Guide §6.
---
## 7. Referenzen
- `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` — Phasen AF
- `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` — APIs, Tests
- `activity_data_canon.py``ACTIVITY_LOG_PATCHABLE_COLUMNS`, Legacy-Map

View File

@ -4,6 +4,12 @@
**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.
**Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge AF.
**Composite-Parameter (EAV, JSONB, Archetypen):** detailliertes Umsetzungskonzept für Agenten: [`ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`](./ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md).
**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**.
---
## 1. Produktions-Migrationen (Pflicht)
@ -41,7 +47,9 @@
| 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`. |
| 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`, `merge_column_backed_and_eav_metrics`. |
**Spalten vs. EAV (Lesepfad):** `merge_column_backed_and_eav_metrics` / `get_activity_session_logical_unit` / `enrich_sessions_with_metrics` werten Parameter mit `source_field` **primär aus `activity_log`** aus; EAV ist Fallback (z.B. Legacy) oder für Parameter ohne Spalte. **Kein** automatischer Spalte→EAV-Schreib-Sync mehr in `run_activity_post_write_hooks` / Import-Hooks (vermeidet Doppelhaltung).
**Regeln für Agenten:**
@ -49,6 +57,8 @@
- 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`.
**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
---
## 4. API (Ist / geplant)
@ -81,10 +91,23 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a
## 5. Agent-Checkliste (nächste Iterationen)
**Layer 2a (Platzhalter Aktivität):** Abgleich Registry ↔ Resolver ↔ Layer 1 — [`ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md`](./ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md) (Issue #53). **Schritt 2:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` und Korrektur `trainingstyp_verteilung` in der Registry.
Siehe **Phasen AF** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz:
- [x] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik) — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
- [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit fortlaufend — siehe `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §4 Phase B).
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
- [ ] **Phase E:** Archetypen ausbauen + CSV-Typkonvertierung vollständig + Mapping-UX.
- [ ] **Phase F:** Härtung Prod (Indizes, Observability, Doku).
Legacy-Punkte:
- [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`.
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
---
@ -116,6 +139,7 @@ Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_val
- 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`
- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
---

File diff suppressed because it is too large Load Diff

View File

@ -120,9 +120,17 @@ 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).
- **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` inkl. `name_*` / `description_*`; **`{{training_parameters_glossary_md}}`** = Markdown-Legende aller aktiven Parameter (KI).
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
### Updates (16.04.2026 - Aktivität Phase A abgeschlossen, Phase B gestartet)
- **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).
- **Issue #53 / Layer 2a:** `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` — alle 20 Aktivitäts-Platzhalter gegen Layer 1 geprüft; Registry-Fix `activity_summary.resolver_function``get_activity_summary`.
- **Layer 2a Schritt 2:** Registry-Texte `activity_detail`, `training_sessions_recent_json` (dynamische session_metrics, Merge-Kanon); `trainingstyp_verteilung` Metadaten an Phase-0c-Code angeglichen.
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **MifflinSt 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).

View File

@ -34,6 +34,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
},
},
},
# Kanon: nur Kern/spine + „heiße“ Metriken → activity_log. Erweiterte Parameter → training_parameters / EAV
# (siehe backend/data_layer/activity_data_canon.py).
"activity": {
"table": "activity_log",
"fields": {
@ -63,16 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"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 (110)"},
"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",

View File

@ -0,0 +1,61 @@
"""
Kanonische Aufteilung activity_log vs. EAV für Aktivitätssessions.
- **Kern / Mapping-Ziele für activity_log:** ausschließlich die Keys aus
``csv_parser.module_registry.MODULE_DEFINITIONS["activity"].fields`` (keine zweite hartcodierte Liste).
- **Alle anderen Attribute:** ``training_parameters`` + Attributprofil (Kategorie/Typ) EAV;
Lesefallback für bekannte Legacy-Spalten siehe unten.
Normative Doku: .claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md,
ACTIVITY_SCALAR_KANON_TABLE.md
"""
from __future__ import annotations
from typing import Dict, Final
from csv_parser.module_registry import get_module_definition
def get_activity_module_registry_field_keys() -> frozenset[str]:
"""Keys des Universal-CSV-Moduls ``activity`` (= feste activity_log-Kernfelder / Mapping-Ziele)."""
mod = get_module_definition("activity")
if not mod:
return frozenset()
return frozenset((mod.get("fields") or {}).keys())
# Gleiche Menge wie ``MODULE_DEFINITIONS["activity"].fields`` — zur Laufzeit aus der Registry abgeleitet.
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module_registry_field_keys()
# Teil-UPDATEs (Import): alle Kernfelder außer ``date`` (Identität / Duplikat-Key).
ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"}
# Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL.
# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV.
ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset(
{
"min_hr",
"pace_min_per_km",
"cadence",
"avg_power",
"elevation_gain",
"temperature_celsius",
"humidity_percent",
"avg_hr_percent",
"kcal_per_km",
}
)
# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert).
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = {
"min_hr": "hr_min",
"pace_min_per_km": "pace_min_per_km",
"cadence": "cadence",
"avg_power": "avg_power",
"elevation_gain": "elevation_gain",
"temperature_celsius": "temperature_celsius",
"humidity_percent": "humidity_percent",
"avg_hr_percent": "avg_hr_percent",
"kcal_per_km": "kcal_per_km",
}

View File

@ -10,6 +10,7 @@ Functions:
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
- get_training_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
All functions return structured data (dict) without formatting.
Use placeholder_resolver.py for formatted strings for AI.
@ -1179,3 +1180,32 @@ def get_training_sessions_recent_weeks_data(
},
}
)
def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
"""
Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
Args:
profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
"""
_ = profile_id
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT key, name_de, name_en, description_de, description_en,
data_type, unit, category
FROM training_parameters
WHERE is_active = true
ORDER BY category, key
"""
)
rows = [r2d(r) for r in cur.fetchall()]
return {
"parameters": rows,
"meta": {"count": len(rows), "scope": "global_active_catalog"},
}

View File

@ -1,5 +1,5 @@
"""
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, SpaltenEAV).
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
@ -15,7 +15,7 @@ from typing import Any, Dict, List, Mapping, 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
from data_layer.activity_data_canon import get_activity_module_registry_field_keys
logger = logging.getLogger(__name__)
@ -51,10 +51,8 @@ _ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "a
def activity_registry_field_keys() -> frozenset[str]:
mod = get_module_definition("activity")
if not mod:
return frozenset()
return frozenset((mod.get("fields") or {}).keys())
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
return get_activity_module_registry_field_keys()
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
@ -248,7 +246,7 @@ def insert_activity_csv_minimal(
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
"""Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten."""
"""Auto-Eval (falls aktiv). Kein Spalte→EAV-Sync: Lesepfad merge_column_backed_and_eav_metrics."""
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
cur.execute(
"""
@ -269,7 +267,6 @@ def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
_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(
@ -286,7 +283,7 @@ def run_activity_post_write_hooks_import(
kcal_active: Any,
kcal_resting: Any,
) -> None:
"""Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict."""
"""Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks)."""
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
try:
activity_dict = {
@ -308,7 +305,6 @@ def run_activity_post_write_hooks_import(
_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 merge_activity_csv_module_fields(

View File

@ -9,37 +9,13 @@ import logging
from decimal import Decimal
from typing import Any, Dict, List, Mapping, Optional, Sequence
from csv_parser.module_registry import get_module_definition
from data_layer.activity_data_canon import (
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
)
logger = logging.getLogger(__name__)
# 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",
"elevation_gain",
"temperature_celsius",
"humidity_percent",
"avg_hr_percent",
"kcal_per_km",
"notes",
}
)
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
{
@ -95,6 +71,8 @@ def merge_parameter_schema_rows(
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"description_de": r.get("description_de"),
"description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
@ -114,6 +92,8 @@ def merge_parameter_schema_rows(
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"description_de": r.get("description_de"),
"description_en": r.get("description_en"),
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
@ -157,7 +137,9 @@ def resolve_activity_attribute_schema(
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.key, tp.name_de, tp.name_en,
tp.description_de, tp.description_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
@ -175,7 +157,9 @@ def resolve_activity_attribute_schema(
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.key, tp.name_de, tp.name_en,
tp.description_de, tp.description_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
@ -188,6 +172,16 @@ def resolve_activity_attribute_schema(
return merge_parameter_schema_rows(category_rows, type_rows)
def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
"""Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
return {
"name_de": schema_row.get("name_de"),
"name_en": schema_row.get("name_en"),
"description_de": schema_row.get("description_de"),
"description_en": schema_row.get("description_en"),
}
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
@ -276,20 +270,26 @@ def upsert_session_metrics_from_csv_mapped(
training_type_id: Optional[int],
) -> None:
"""
EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen).
EAV für Trainingsparameter aus CSV.
Kernfelder (Datum, Start, Distanz, HF, ) schreibt der Executor nach activity_log;
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
UI-/Auswertungs-Schema fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
nur in ``activity_log``-Kernfeldern).
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
Hat ein Parameter ``source_field`` (Semantik aus ``activity_log``), wird EAV nur dann **nicht**
geschrieben, wenn diese Spalte nach dem Import bereits befüllt ist sonst gäbe es doppelte
Speicherung und der Merge würde ohnehin die Spalte bevorzugen. Ist die Spalte leer (z. B. Feld
nur noch über EAV / Custom-Mapping, ohne Registry-Patch), schreibt der Import den Wert aus
``mapped`` nach EAV analog zum Lesepfad (Spalte zuerst, sonst EAV).
"""
cur.execute(
"SELECT profile_id FROM activity_log WHERE id = %s",
(activity_log_id,),
)
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
mod = get_module_definition("activity") or {}
activity_registry_keys = frozenset((mod.get("fields") or {}).keys())
header = dict(row)
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
for spec in schema:
pkey = spec["key"]
@ -298,8 +298,13 @@ def upsert_session_metrics_from_csv_mapped(
raw = mapped[pkey]
if raw is None or raw == "":
continue
if pkey in activity_registry_keys:
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
continue
sf_raw = spec.get("source_field")
if sf_raw is not None and str(sf_raw).strip():
col = str(sf_raw).strip()
if col in header and header[col] is not None:
continue
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
@ -328,13 +333,113 @@ def upsert_session_metrics_from_csv_mapped(
)
def merge_column_backed_and_eav_metrics(
header: Mapping[str, Any],
schema: Sequence[Dict[str, Any]],
eav_metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
koerzierbar ist in dieser Reihenfolge:
1. ``source_field`` Spalte
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) gleichnamige Spalte
3. EAV-primäre Keys Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
4. sonst EAV
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
"""
eav_by_key = {m["key"]: m for m in eav_metrics}
merged: List[Dict[str, Any]] = []
keys_handled: set[str] = set()
for s in schema:
k = s["key"]
tid = s["training_parameter_id"]
dt = s["data_type"]
unit = s.get("unit")
sf = s.get("source_field")
used_column = False
if sf and isinstance(sf, str) and str(sf).strip():
col = str(sf).strip()
if col in header and header[col] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[col])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
used_column = True
keys_handled.add(k)
except (TypeError, ValueError):
pass
if used_column:
continue
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[k])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
if legacy_col and legacy_col in header and header[legacy_col] is not None:
try:
val = _coerce_raw_value_for_parameter(dt, header[legacy_col])
merged.append(
{
"training_parameter_id": tid,
"key": k,
"data_type": dt,
"unit": unit,
"value": val,
**_metric_human_labels(s),
}
)
keys_handled.add(k)
continue
except (TypeError, ValueError):
pass
if k in eav_by_key:
row = dict(eav_by_key[k])
row.update(_metric_human_labels(s))
merged.append(row)
keys_handled.add(k)
merged.sort(key=lambda x: x["key"])
return merged
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.
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
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.
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
Kanon: Spaltenwerte werden bei merge_column_backed_and_eav_metrics beim Lesen berücksichtigt; keine
doppelte Speicherung. Funktion bleibt für optionale Admin-/Reparatur-Skripte.
"""
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
row = cur.fetchone()
@ -516,47 +621,37 @@ def replace_activity_session_metrics(
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]:
def get_activity_session_logical_unit(
cur,
profile_id: str,
activity_log_id: str,
*,
use_form_training_context: bool = False,
form_training_category: Optional[str] = None,
form_training_type_id: Optional[int] = None,
) -> 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")
)
if use_form_training_context:
cat = form_training_category
if isinstance(cat, str):
cat = cat.strip() or None
tid = form_training_type_id
else:
cat = header.get("training_category")
tid = header.get("training_type_id")
if tid is not None:
try:
tid = int(tid)
except (TypeError, ValueError):
tid = None
schema = resolve_activity_attribute_schema(cur, cat, tid)
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"])
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
return {
"header": header,
"schema": schema,
@ -565,17 +660,33 @@ def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str
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."""
"""
Mutates each session dict: adds key 'session_metrics' (list).
Kombiniert EAV mit activity_log-Spalten für Parameter mit source_field (kanonisch: Spalte),
analog zu get_activity_session_logical_unit ohne doppelte EAV-Speicherung beim Import.
"""
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 * FROM activity_log WHERE id IN ({ph})",
ids,
)
headers_by_id: Dict[str, Dict[str, Any]] = {}
for r in cur.fetchall():
h = dict(r)
headers_by_id[str(h["id"])] = h
cur.execute(
f"""
SELECT
m.activity_log_id,
m.training_parameter_id,
tp.key,
tp.data_type,
tp.unit,
@ -603,8 +714,42 @@ def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
else:
val = r["value_bool"]
by_act.setdefault(aid, []).append(
{"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val}
{
"training_parameter_id": r["training_parameter_id"],
"key": r["key"],
"data_type": dt,
"unit": r["unit"],
"value": val,
}
)
schema_cache: Dict[tuple[Any, Any], List[Dict[str, Any]]] = {}
def _schema(cat: Any, tid: Any) -> List[Dict[str, Any]]:
cache_key = (cat, tid)
if cache_key not in schema_cache:
schema_cache[cache_key] = resolve_activity_attribute_schema(cur, cat, tid)
return schema_cache[cache_key]
for s in sessions:
aid = str(s.get("id"))
s["session_metrics"] = by_act.get(aid, [])
header = headers_by_id.get(aid)
if not header:
s["session_metrics"] = []
continue
schema = _schema(header.get("training_category"), header.get("training_type_id"))
eav_list = by_act.get(aid, [])
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
s["session_metrics"] = [
{
"key": m["key"],
"data_type": m["data_type"],
"unit": m["unit"],
"value": m["value"],
"name_de": m.get("name_de"),
"name_en": m.get("name_en"),
"description_de": m.get("description_de"),
"description_en": m.get("description_en"),
}
for m in merged
]

View File

@ -0,0 +1,115 @@
-- Migration 057: Kanon EAV-primär für erweiterte Trainingsmetriken
-- Date: 2026-04-15
-- activity_log-Spalten bleiben erhalten (Lesefallback / API); training_parameters.source_field
-- wird für diese Keys entfernt. Idempotenter EAV-Backfill aus Spalten (wie 055), dann source_field NULL.
-- Siehe: backend/data_layer/activity_data_canon.py
-- min_hr (Spalte hr_min)
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;
UPDATE training_parameters
SET source_field = NULL
WHERE key IN (
'min_hr',
'pace_min_per_km',
'cadence',
'avg_power',
'elevation_gain',
'temperature_celsius',
'humidity_percent',
'avg_hr_percent',
'kcal_per_km'
);
DO $$
BEGIN
RAISE NOTICE 'Migration 057: EAV-primary canon — backfill + source_field cleared for extended metrics';
END $$;

View File

@ -1,7 +1,7 @@
"""
Activity Metrics Placeholder Registrations
Registers 17 Aktivitäts-Platzhalter hier; 3 Session-/Erholungs-Keys in activity_session_insights.py (20 gesamt).
Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP).
Evidence-based metadata with clear tagging of source.
@ -43,7 +43,7 @@ def register_activity_group_1():
category="Aktivität",
description="Zusammenfassung der letzten 14 Tage Aktivität",
resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_activity_summary",
resolver_function="get_activity_summary",
data_layer_module=None,
data_layer_function=None,
source_tables=["activity_log", "training_types"],
@ -127,17 +127,23 @@ def register_activity_group_1():
activity_detail_metadata = PlaceholderMetadata(
key="activity_detail",
category="Aktivität",
description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)",
description=(
"Letzte 14 Tage: pro Session Kopfzeile (activity_log) plus gemergte Profil-Metriken "
"(dynamische Keys je training_category / training_type_id)"
),
resolver_module="backend/placeholder_resolver.py",
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 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."
"Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
"session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
"Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
"Leseregel Kanon: activity_log-Spalte (source_field, Registry-Feld, Legacy-Spalte für "
"EAV-primäre Keys) schlägt EAV, wenn beide Werte liefern. "
"Layer 2a: Zeilen mit „| EAV: key=value; …“ nur für nicht-leere session_metrics; "
"die Menge der Keys ist admin-/profilabhängig, kein festes Prompt-Schema."
),
business_meaning=(
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
@ -167,8 +173,10 @@ def register_activity_group_1():
),
known_limitations=(
"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."
"(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
"Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen "
"Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys "
"außerhalb des Schemas."
),
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
layer_2a_decision="get_activity_detail (Formatierung)",
@ -211,56 +219,47 @@ def register_activity_group_1():
trainingstyp_verteilung_metadata = PlaceholderMetadata(
key="trainingstyp_verteilung",
category="Aktivität",
description="Trainingstypen-Verteilung der letzten 14 Tage als JSON",
description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile",
resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_trainingstyp_verteilung",
data_layer_module=None,
data_layer_function=None,
source_tables=["activity_log", "training_types"],
resolver_function="get_trainingstyp_verteilung",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_type_distribution_data",
source_tables=["activity_log"],
semantic_contract=(
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. "
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), "
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend."
"Layer 1: get_training_type_distribution_data — Anteil je training_category am "
"Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
"Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
),
business_meaning=(
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
),
unit="json",
unit="text",
time_window="14d",
output_type=OutputType.JSON,
output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED,
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}",
example_output=(
'{"Krafttraining": {"count": 5, "duration_min": 180, "percentage": 57}, '
'"Ausdauer": {"count": 4, "duration_min": 90, "percentage": 29}, '
'"Mobilität": {"count": 3, "duration_min": 45, "percentage": 14}}'
),
format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
example_output="cardio: 45%, strength: 30%, mobility: 15%",
minimum_data_requirements=None,
quality_filter_policy=None,
confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.",
confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="{}"
legacy_display="Keine kategorisierten Trainings"
),
known_limitations=(
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
"Aggregation direkt im Resolver. "
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. "
"JOIN mit training_types für Typ-Namen. "
"EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)."
"Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
"Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
"Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
),
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)",
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)",
layer_1_decision="activity_metrics.get_training_type_distribution_data",
layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
layer_2b_reuse_possible=True,
architecture_alignment=(
"PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, "
"but no data layer separation. Should be refactored."
),
issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing"
architecture_alignment="Phase 0c — Layer 1 + Formatierung",
issue_53_alignment="Layer 1"
)
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)

View File

@ -130,8 +130,8 @@ 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, "
"session_id, session_metrics[] aus EAV)"
"JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics[] — gemergte Profil-Metriken "
"(dynamische Keys)"
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_json",
@ -139,9 +139,16 @@ def register_activity_session_insights():
data_layer_function="get_training_sessions_recent_weeks_data",
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
semantic_contract=(
"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."
"Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
"session_metrics[]. "
"session_metrics: effektive Liste nach merge_column_backed_and_eav_metrics — Einträge mit "
"training_parameter_id, key, data_type, unit, value, name_de/name_en, description_de/description_en; "
"nur Parameter aus Attributschema "
"(training_category_parameter + training_type_parameter Overrides), keys sortiert. "
"Kanon Lesen: activity_log-Spalte vor EAV bei Konflikt. "
"meta: weeks_requested, days_loaded, session_count, confidence. "
"Default ca. 4 ISO-Wochen (28 Tage Rohdatenfenster)."
),
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
unit="JSON string",
@ -160,8 +167,12 @@ 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. "
"session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind."
"Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. "
"session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). "
"Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema "
"für Downstream-Parsing harter Logik verwenden. "
"Für KI-Semantik zusätzlich {{training_parameters_glossary_md}} (gesamter aktiver Katalog) in den Prompt legen. "
"Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase."
),
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
layer_2a_decision="_safe_json('training_sessions_recent_json')",
@ -183,5 +194,61 @@ def register_activity_session_insights():
_ev(pj, "known_limitations", EvidenceType.MIXED)
register_placeholder(pj)
md_gloss = PlaceholderMetadata(
key="training_parameters_glossary_md",
category="Aktivität",
description=(
"Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). "
"Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)."
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="get_training_parameters_glossary_md",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_parameters_ki_glossary_data",
source_tables=["training_parameters"],
semantic_contract=(
"SELECT auf training_parameters WHERE is_active; sortiert category, key. "
"profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog."
),
business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern",
unit="Markdown",
time_window="n/a (Katalog-Snapshot)",
output_type=OutputType.TEXT_SUMMARY,
placeholder_type=PlaceholderType.INTERPRETED,
format_hint="GitHub-Flavored Markdown-Tabelle",
example_output="| Feld (key) | DE | EN | Beschreibung DE | … |",
minimum_data_requirements="Optional leer → Kurztext statt Tabelle",
quality_filter_policy=None,
confidence_logic="Immer verfügbar wenn DB erreichbar",
missing_value_policy=MissingValuePolicy(
available=False,
value_raw=None,
missing_reason="no_data",
legacy_display="Keine aktiven Trainingsparameter im Katalog.",
),
known_limitations=(
"Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). "
"Tabellen können bei großem Katalog lang werden."
),
layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data",
layer_2a_decision="get_training_parameters_glossary_md",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c",
issue_53_alignment="Layer 2a",
evidence={},
)
for f in (
"key", "category", "description", "resolver_module", "resolver_function",
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
"example_output", "minimum_data_requirements", "confidence_logic",
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
):
_ev(md_gloss, f)
_ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED)
_ev(md_gloss, "known_limitations", EvidenceType.MIXED)
register_placeholder(md_gloss)
register_activity_session_insights()

View File

@ -35,6 +35,7 @@ from data_layer.activity_metrics import (
get_training_frequency_by_type_data,
get_training_inter_session_gap_data,
get_training_sessions_recent_weeks_data,
get_training_parameters_ki_glossary_data,
)
from data_layer.recovery_metrics import (
get_sleep_duration_data,
@ -426,7 +427,8 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
k, v = m.get("key"), m.get("value")
if k is None or v is None:
continue
eav_parts.append(f"{k}={v}")
label = m.get("name_de") or m.get("name_en") or k
eav_parts.append(f"{label} ({k})={v}")
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
lines.append(
f"{activity['date']}: {activity['activity_type']} "
@ -456,6 +458,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
return ", ".join(parts)
def get_training_parameters_glossary_md(profile_id: str) -> str:
"""
Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
Für KI neben session_metrics / training_sessions_recent_json.
"""
data = get_training_parameters_ki_glossary_data(profile_id)
params = data.get("parameters") or []
if not params:
return "Keine aktiven Trainingsparameter im Katalog."
def cell(x: object) -> str:
if x is None:
return ""
return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
lines = [
"| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
"|---|---|---|---|---|---|---|---|",
]
for p in params:
lines.append(
"| "
+ " | ".join(
[
cell(p.get("key")),
cell(p.get("name_de")),
cell(p.get("name_en")),
cell(p.get("description_de")),
cell(p.get("description_en")),
cell(p.get("data_type")),
cell(p.get("unit")),
cell(p.get("category")),
]
)
+ " |"
)
return "\n".join(lines)
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
"""
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
@ -1524,7 +1565,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
# Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores)
# Training / Aktivität (20 Keys: 17 activity_metrics + 3 activity_session_insights; activity_score hier, nicht unter Meta Scores)
'{{activity_summary}}': get_activity_summary,
'{{activity_detail}}': get_activity_detail,
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
@ -1545,6 +1586,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
'{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
@ -1749,6 +1791,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
'{{training_parameters_glossary_md}}',
],
'schlaf': [
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',

View File

@ -32,6 +32,7 @@ from data_layer.activity_persistence_orchestrator import (
new_activity_id,
)
from data_layer.activity_time_normalize import normalize_activity_start
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@ -71,6 +72,12 @@ def _activity_rows_after_list_query(cur):
return rows
def _return_activity_list_rows(cur, rows: list) -> list:
"""Layer-1: gemergte session_metrics wie Detail-Pfad (Batch)."""
enrich_sessions_with_metrics(cur, rows)
return rows
# Evaluation import with error handling (Phase 1.2)
try:
from evaluation_helper import evaluate_and_save_activity
@ -140,7 +147,7 @@ def list_activity(
""",
(pid, d0, d1, limit),
)
return _activity_rows_after_list_query(cur)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute(
f"""
SELECT * FROM activity_log
@ -152,7 +159,9 @@ def list_activity(
""",
(pid, d0, d1, limit),
)
return [r2d(r) for r in cur.fetchall()]
return _return_activity_list_rows(
cur, [r2d(r) for r in cur.fetchall()]
)
if days is not None:
if collapse_duplicate_sessions:
@ -173,7 +182,7 @@ def list_activity(
""",
(pid, days, limit, offset),
)
return _activity_rows_after_list_query(cur)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute(
f"""
SELECT * FROM activity_log
@ -203,7 +212,7 @@ def list_activity(
""",
(pid, limit, offset),
)
return _activity_rows_after_list_query(cur)
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
cur.execute(
f"""
SELECT * FROM activity_log
@ -214,7 +223,7 @@ def list_activity(
""",
(pid, limit, offset),
)
return [r2d(r) for r in cur.fetchall()]
return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()])
@router.post("")
@ -362,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)):
return get_mappable_activity_field_catalog(cur, pid)
@router.get("/attribute-schema")
def get_activity_attribute_schema(
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""
Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session
gleiche Logik wie resolve_activity_attribute_schema.
"""
from data_layer.activity_session_metrics import resolve_activity_attribute_schema
cat = (training_category or "").strip() or None
with get_db() as conn:
cur = get_cursor(conn)
schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
return {"schema": schema}
@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."""
@ -413,6 +441,12 @@ def replace_activity_metrics(
@router.get("/{eid}")
def get_activity_session(
eid: str,
use_form_schema: bool = Query(
False,
description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
),
training_category: Optional[str] = Query(None),
training_type_id: Optional[int] = Query(None),
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
@ -426,7 +460,14 @@ def get_activity_session(
try:
with get_db() as conn:
cur = get_cursor(conn)
unit = get_activity_session_logical_unit(cur, pid, eid)
unit = get_activity_session_logical_unit(
cur,
pid,
eid,
use_form_training_context=use_form_schema,
form_training_category=training_category,
form_training_type_id=training_type_id,
)
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
unit["header"] = serialize_dates(unit["header"])

View File

@ -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,))

View File

@ -0,0 +1,179 @@
"""
Diagnose: Was liegt in activity_session_metrics (EAV) vs. activity_log?
Ausführung (mit gesetzten DB_*-Variablen wie die App, z. B. aus .env):
cd backend
python scripts/inspect_activity_eav.py
Lokal ohne Docker-Hostname: z. B. ``set DB_HOST=127.0.0.1`` (Windows) / ``export DB_HOST=127.0.0.1``,
Port/User/Pass wie in der laufenden Postgres-Instanz.
Im Backend-Container (Compose-Service meist ``backend``, Arbeitsverzeichnis ``/app``):
docker compose exec backend python /app/scripts/inspect_activity_eav.py
Optional:
python scripts/inspect_activity_eav.py --limit 30
python scripts/inspect_activity_eav.py --profile <uuid>
python scripts/inspect_activity_eav.py --activity <activity_log uuid>
Keine Schreibzugriffe nur SELECT.
"""
from __future__ import annotations
import argparse
import os
import sys
# backend/ als Import-Root
_BACKEND_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _BACKEND_ROOT not in sys.path:
sys.path.insert(0, _BACKEND_ROOT)
def _val_row(r: dict) -> str | None:
dt = r.get("data_type")
if dt == "integer":
v = r.get("value_int")
return str(v) if v is not None else None
if dt == "float":
v = r.get("value_num")
return str(v) if v is not None else None
if dt == "string":
v = r.get("value_text")
return repr(v) if v is not None else None
if dt == "boolean":
v = r.get("value_bool")
return str(v) if v is not None else None
return None
def main() -> None:
parser = argparse.ArgumentParser(description="EAV activity_session_metrics inspizieren")
parser.add_argument("--limit", type=int, default=40, help="Zeilen Report A/B")
parser.add_argument("--profile", type=str, default=None, help="profile_id filtern")
parser.add_argument("--activity", type=str, default=None, help="activity_log.id (einzelne Session)")
args = parser.parse_args()
from db import get_db, get_cursor
if args.activity:
with get_db() as conn:
with get_cursor(conn) as cur:
cur.execute(
"""
SELECT al.id, al.profile_id, al.date, al.start_time, al.source,
al.training_category, al.training_type_id, al.activity_type,
al.duration_min, al.kcal_active, al.hr_avg, al.hr_max, al.distance_km
FROM activity_log al
WHERE al.id = %s::uuid
""",
(args.activity,),
)
h = cur.fetchone()
if not h:
print("activity_log: keine Zeile für diese id")
return
print("=== activity_log (Kopfzeile) ===")
for k, v in dict(h).items():
print(f" {k}: {v}")
cur.execute(
"""
SELECT m.id AS metric_id, tp.key, tp.data_type, tp.source_field,
m.value_num, m.value_int, m.value_text, m.value_bool, m.updated_at
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id = %s::uuid
ORDER BY tp.key
""",
(args.activity,),
)
rows = cur.fetchall()
print(f"\n=== activity_session_metrics ({len(rows)} Zeilen) ===")
for r in rows:
d = dict(r)
print(
f" {d['key']} ({d['data_type']}) "
f"value={_val_row(d)!r} source_field={d.get('source_field')!r} "
f"updated_at={d.get('updated_at')}"
)
if not rows:
print(" (keine EAV-Zeilen)")
return
prof_filter = ""
if args.profile:
prof_filter = " AND al.profile_id = %s::uuid "
params_a: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
params_b: tuple = (args.profile, args.limit) if args.profile else (args.limit,)
q_recent_eav = f"""
SELECT
al.id AS activity_id,
al.profile_id,
al.date,
al.start_time,
al.source,
al.training_type_id,
al.training_category,
tp.key AS parameter_key,
tp.data_type,
tp.source_field AS tp_source_field,
m.value_num,
m.value_int,
m.value_text,
m.value_bool,
m.updated_at
FROM activity_session_metrics m
JOIN activity_log al ON al.id = m.activity_log_id
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE 1=1 {prof_filter}
ORDER BY m.updated_at DESC NULLS LAST, al.date DESC, al.start_time DESC
LIMIT %s
"""
q_csv_no_eav = f"""
SELECT
al.id AS activity_id,
al.profile_id,
al.date,
al.start_time,
al.source,
al.training_type_id,
al.training_category,
(SELECT COUNT(*) FROM activity_session_metrics m WHERE m.activity_log_id = al.id) AS eav_count
FROM activity_log al
WHERE al.source = 'csv' {prof_filter}
ORDER BY al.date DESC, al.start_time DESC
LIMIT %s
"""
with get_db() as conn:
with get_cursor(conn) as cur:
print("=== A) Neueste EAV-Zeilen (join activity_log + training_parameters) ===\n")
cur.execute(q_recent_eav, params_a)
for r in cur.fetchall():
d = dict(r)
v = _val_row(d)
print(
f"{d['date']} {d['start_time']} | {d['activity_id']} | src={d['source']!r} | "
f"type={d['training_type_id']} cat={d['training_category']!r} | "
f"{d['parameter_key']}={v!r} (tp.source_field={d.get('tp_source_field')!r})"
)
print("\n=== B) Neueste CSV-importierte Sessions: EAV-Anzahl pro Zeile ===\n")
cur.execute(q_csv_no_eav, params_b)
for r in cur.fetchall():
d = dict(r)
print(
f"{d['date']} {d['start_time']} | {d['activity_id']} | "
f"type={d['training_type_id']} | eav_count={d['eav_count']}"
)
print("\nFertig. Für eine Session im Detail: --activity <uuid>")
if __name__ == "__main__":
main()

View File

@ -1,14 +1,17 @@
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
import uuid
from unittest.mock import patch
import pytest
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
enrich_sessions_with_metrics,
merge_column_backed_and_eav_metrics,
merge_parameter_schema_rows,
resolve_activity_attribute_schema,
upsert_session_metrics_from_csv_mapped,
_row_value_tuple,
_validate_single_value,
)
@ -94,6 +97,52 @@ def test_merge_type_overrides_required_and_sort():
assert merged[0]["required"] is True
def test_merge_parameter_schema_includes_descriptions():
cat = [
{
"training_parameter_id": 1,
"cat_sort": 0,
"cat_required": False,
"cat_ui_group": None,
"key": "custom_w",
"name_de": "Leistung",
"name_en": "Watts",
"description_de": "Mittlere 5-Min-Leistung",
"description_en": "5 min average power",
"param_category": "performance",
"data_type": "integer",
"unit": "W",
"validation_rules": {},
"source_field": None,
}
]
merged = merge_parameter_schema_rows(cat, [])
assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung"
assert merged[0]["description_en"] == "5 min average power"
def test_merge_column_backed_includes_human_labels_from_schema():
schema = [
{
"training_parameter_id": 1,
"key": "watts",
"data_type": "integer",
"unit": "W",
"validation_rules": {},
"source_field": "avg_power",
"name_de": "Leistung",
"name_en": "Power",
"description_de": "Gerätewert",
"description_en": "Device reading",
}
]
out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, [])
assert len(out) == 1
assert out[0]["value"] == 200
assert out[0]["name_de"] == "Leistung"
assert out[0]["description_en"] == "Device reading"
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)
@ -171,22 +220,39 @@ def test_resolve_loads_category_from_training_type_id():
assert cur.executes[0][1] == (42,)
def test_enrich_sessions_batch():
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
def test_enrich_sessions_batch(mock_resolve):
aid = str(uuid.uuid4())
bid = str(uuid.uuid4())
class _Cur:
def __init__(self):
self.params = None
self._fetch_n = 0
def execute(self, sql, params=None):
self.sql = sql
self.params = params
def fetchall(self):
self._fetch_n += 1
if self._fetch_n == 1:
return [
{
"id": uuid.UUID(aid),
"training_category": None,
"training_type_id": None,
},
{
"id": uuid.UUID(bid),
"training_category": None,
"training_type_id": None,
},
]
return [
{
"activity_log_id": uuid.UUID(aid),
"training_parameter_id": 3,
"key": "rpe",
"data_type": "integer",
"unit": None,
@ -199,6 +265,280 @@ def test_enrich_sessions_batch():
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[0]["session_metrics"] == []
assert sessions[1]["session_metrics"] == []
def test_merge_column_backed_prefers_column_over_stale_eav():
schema = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"validation_rules": {},
"source_field": "hr_avg",
}
]
eav = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"hr_avg": 140.0}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 140.0
def test_merge_falls_back_to_eav_when_column_empty():
schema = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"validation_rules": {},
"source_field": "hr_avg",
}
]
eav = [
{
"training_parameter_id": 1,
"key": "hr_avg",
"data_type": "float",
"unit": "bpm",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"hr_avg": None}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 99.0
def test_merge_ignores_eav_when_parameter_not_in_schema():
"""Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste."""
schema = []
eav = [
{
"training_parameter_id": 2,
"key": "custom_param",
"data_type": "string",
"unit": None,
"value": "x",
}
]
out = merge_column_backed_and_eav_metrics({}, schema, eav)
assert out == []
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
schema = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"validation_rules": {},
"source_field": None,
}
]
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, [])
assert len(out) == 1
assert out[0]["key"] == "min_hr"
assert out[0]["value"] == 88
def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both():
"""Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min)."""
schema = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 9,
"key": "min_hr",
"data_type": "integer",
"unit": "bpm",
"value": 100,
}
]
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 88
def test_merge_registry_key_prefers_activity_log_column_over_eav():
"""Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV."""
schema = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"validation_rules": {},
"source_field": None,
}
]
eav = [
{
"training_parameter_id": 3,
"key": "duration_min",
"data_type": "float",
"unit": "min",
"value": 99.0,
}
]
out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav)
assert len(out) == 1
assert out[0]["value"] == 45.0
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema):
"""Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""
mock_schema.return_value = [
{
"key": "avg_hr",
"training_parameter_id": 42,
"data_type": "integer",
"validation_rules": {"min": 30, "max": 220},
"source_field": "hr_avg",
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {
"profile_id": "00000000-0000-0000-0000-000000000001",
"hr_avg": 130,
}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"avg_hr": 130},
"cardio",
1,
)
assert cur.asm_inserts == 0
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_writes_eav_when_source_field_but_column_empty(mock_schema):
"""source_field gesetzt, activity_log-Spalte leer — Wert aus mapped nur in EAV (kein Registry-Patch)."""
mock_schema.return_value = [
{
"key": "avg_hr",
"training_parameter_id": 42,
"data_type": "integer",
"validation_rules": {"min": 30, "max": 220},
"source_field": "hr_avg",
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {
"profile_id": "00000000-0000-0000-0000-000000000001",
"hr_avg": None,
}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"avg_hr": 130},
"cardio",
1,
)
assert cur.asm_inserts == 1
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
def test_upsert_csv_writes_eav_when_no_source_field(mock_schema):
mock_schema.return_value = [
{
"key": "custom_note",
"training_parameter_id": 99,
"data_type": "string",
"validation_rules": {},
"source_field": None,
}
]
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"custom_note": "x"},
"cardio",
1,
)
assert cur.asm_inserts == 1
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve):
"""Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen."""
class Cur:
def __init__(self):
self.asm_inserts = 0
def execute(self, sql, params=None):
if "INSERT INTO activity_session_metrics" in sql:
self.asm_inserts += 1
def fetchone(self):
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
cur = Cur()
upsert_session_metrics_from_csv_mapped(
cur,
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002",
{"stola": 12},
"cardio",
1,
)
assert cur.asm_inserts == 0

View File

@ -440,6 +440,141 @@ a.analysis-split__nav-item {
}
}
/* Admin: Session-Metriken / Attributprofile — volle Breite, linksbündig (nicht globale 90px-Zahlfelder) */
.activity-attribute-profiles .aaf-stack {
max-width: 42rem;
}
.activity-attribute-profiles .aaf-field {
margin-bottom: 1rem;
}
.activity-attribute-profiles .aaf-label {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text1);
text-align: left;
margin-bottom: 6px;
line-height: 1.35;
}
.activity-attribute-profiles .aaf-sublabel {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text2);
text-align: left;
margin-bottom: 4px;
}
.activity-attribute-profiles .aaf-hint {
font-size: 12px;
color: var(--text3);
text-align: left;
margin: 6px 0 0;
line-height: 1.45;
}
.activity-attribute-profiles .aaf-input,
.activity-attribute-profiles textarea.aaf-input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
text-align: left;
font-family: var(--font);
font-size: 15px;
font-weight: 500;
color: var(--text1);
background: var(--surface2);
border: 1.5px solid var(--border2);
border-radius: 8px;
transition: border-color 0.15s;
}
.activity-attribute-profiles textarea.aaf-input {
resize: vertical;
min-height: 4.5rem;
font-weight: 400;
}
.activity-attribute-profiles .aaf-input:focus {
outline: none;
border-color: var(--accent);
}
.activity-attribute-profiles .aaf-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 560px) {
.activity-attribute-profiles .aaf-split {
grid-template-columns: 1fr;
}
}
.activity-attribute-profiles .aaf-field-select {
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.activity-attribute-profiles .aaf-field-select:last-child {
border-bottom: none;
}
.activity-attribute-profiles .aaf-field-select .form-label {
display: block;
text-align: left;
margin-bottom: 6px;
font-weight: 600;
flex: unset;
}
.activity-attribute-profiles .aaf-field-select .form-input,
.activity-attribute-profiles .aaf-field-select select.form-input {
width: 100%;
max-width: none;
min-width: 0;
text-align: left;
box-sizing: border-box;
padding: 10px 12px;
}
.activity-attribute-profiles .aaf-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.activity-attribute-profiles .aaf-toolbar .form-label {
display: block;
text-align: left;
margin-bottom: 6px;
font-weight: 600;
flex: unset;
}
.activity-attribute-profiles .aaf-toolbar__grow {
flex: 1 1 240px;
min-width: 0;
}
.activity-attribute-profiles .aaf-toolbar .form-input,
.activity-attribute-profiles .aaf-toolbar select.form-input {
width: 100%;
min-width: 140px;
max-width: none;
text-align: left;
box-sizing: border-box;
padding: 10px 12px;
}
.activity-attribute-profiles .aaf-toolbar__compact {
flex: 0 0 auto;
}
.activity-attribute-profiles .aaf-toolbar__compact .form-input,
.activity-attribute-profiles .aaf-toolbar__compact select.form-input {
width: 100%;
min-width: 5rem;
}
.activity-attribute-profiles .aaf-inline-edit .form-input,
.activity-attribute-profiles .aaf-inline-edit select.form-input {
text-align: left;
min-width: 4.5rem;
width: auto;
max-width: none;
box-sizing: border-box;
padding: 8px 10px;
}
/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */
.capture-shell {
width: 100%;

View File

@ -96,6 +96,77 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
'training_subcategory',
])
/** activity_log-Spalten, die bereits in EntryForm (Kopfzeile) bearbeitet werden — Profilfeld mit gleichem source_field nicht doppelt anzeigen. */
const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([
'duration_min',
'kcal_active',
'hr_avg',
'hr_max',
'rpe',
'notes',
])
/**
* Bindung Profilparameter Kopfzeile: Entweder source_field zeigt auf eine Kopfspalte,
* oder der Parameter-key ist selbst eine Kopfspalte (häufig nach Migration / ohne source_field).
* @returns {{ headlineCol: string, parameterKey: string } | null}
*/
function activitySchemaHeadlineBinding(s) {
if (!s || !s.key) return null
const sf = s.source_field != null ? String(s.source_field).trim() : ''
if (sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) {
return { headlineCol: sf, parameterKey: s.key }
}
if (ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(s.key)) {
return { headlineCol: s.key, parameterKey: s.key }
}
return null
}
/** training_parameters.category (siehe Migration 013); feste Reihenfolge der Wertegruppen */
const TRAINING_PARAM_CATEGORY_ORDER = [
'physical',
'physiological',
'performance',
'subjective',
'environmental',
]
const TRAINING_PARAM_CATEGORY_LABEL_DE = {
physical: 'Physisch / Bewegung',
physiological: 'Physiologie',
performance: 'Leistung',
subjective: 'Subjektiv und Wahrnehmung',
environmental: 'Umwelt',
}
function compareActivityProfileSchemaRows(a, b) {
const ca = (a.param_category && String(a.param_category).trim().toLowerCase()) || ''
const cb = (b.param_category && String(b.param_category).trim().toLowerCase()) || ''
const ia = TRAINING_PARAM_CATEGORY_ORDER.indexOf(ca)
const ib = TRAINING_PARAM_CATEGORY_ORDER.indexOf(cb)
const ra = ia === -1 ? 1000 : ia
const rb = ib === -1 ? 1000 : ib
if (ra !== rb) return ra - rb
if (ca !== cb) return ca.localeCompare(cb, 'de')
const ga = (a.ui_group && String(a.ui_group).trim()) || ''
const gb = (b.ui_group && String(b.ui_group).trim()) || ''
if (ga !== gb) {
if (!ga) return -1
if (!gb) return 1
return ga.localeCompare(gb, 'de')
}
const sa = Number(a.sort_order) || 0
const sb = Number(b.sort_order) || 0
if (sa !== sb) return sa - sb
return String(a.key).localeCompare(String(b.key), 'de')
}
function sortActivityProfileSchemaRows(rows) {
return [...rows].sort(compareActivityProfileSchemaRows)
}
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
@ -146,48 +217,110 @@ function buildMetricsPayload(schema, draft) {
function SessionMetricsFields({ schema, values, setValues, metrics }) {
const schemaList = Array.isArray(schema) ? schema : []
const headlineDuplicateKeys = new Set(
schemaList.filter((s) => activitySchemaHeadlineBinding(s) != null).map((s) => s.key),
)
const schemaForDisplay = schemaList.filter((s) => activitySchemaHeadlineBinding(s) == null)
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))
const schemaKeys = new Set(schemaForDisplay.map((s) => s.key))
const orphanMetrics = metricRows.filter(
(row) =>
row &&
row.key &&
!schemaKeys.has(row.key) &&
!headlineDuplicateKeys.has(row.key),
)
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
if (schemaForDisplay.length === 0 && orphanMetrics.length === 0) return null
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
const sortedForDisplay = sortActivityProfileSchemaRows(schemaForDisplay)
const profileFieldNodes = []
let lastCategoryKey = null
let lastUiGroup = null
for (const s of sortedForDisplay) {
const catRaw = (s.param_category && String(s.param_category).trim().toLowerCase()) || ''
const catKey = catRaw || '_other'
if (catKey !== lastCategoryKey) {
lastCategoryKey = catKey
lastUiGroup = null
const catTitle =
(catRaw && TRAINING_PARAM_CATEGORY_LABEL_DE[catRaw]) || s.param_category || 'Sonstige'
profileFieldNodes.push(
<div
key={`prof-cat-${catKey}-${profileFieldNodes.length}`}
style={{
fontSize: 12,
fontWeight: 700,
color: 'var(--text2)',
marginTop: profileFieldNodes.length ? 14 : 6,
marginBottom: 6,
letterSpacing: '0.02em',
}}
>
{catTitle}
</div>,
)
}
const ug = (s.ui_group && String(s.ui_group).trim()) || ''
if (ug) {
if (ug !== lastUiGroup) {
lastUiGroup = ug
profileFieldNodes.push(
<div
key={`prof-ug-${catKey}-${ug}-${profileFieldNodes.length}`}
style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 4 }}
>
{ug}
</div>,
)
}
} else {
lastUiGroup = null
}
profileFieldNodes.push(
<div key={s.key} className="form-row">
<label className="form-label">
{s.name_de}
{s.required ? ' *' : ''}
{s.unit ? ` (${s.unit})` : ''}
</label>
{s.data_type === 'boolean' ? (
<input
type="checkbox"
style={{ width: 'auto', marginRight: 'auto' }}
checked={!!values[s.key]}
onChange={(e) => set(s.key, e.target.checked)}
/>
) : s.data_type === 'integer' || s.data_type === 'float' ? (
<input
type="number"
className="form-input"
step={s.data_type === 'integer' ? 1 : 'any'}
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
) : (
<input
type="text"
className="form-input"
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
)}
<span className="form-unit" />
</div>,
)
}
const orphansSorted = [...orphanMetrics].sort((a, b) =>
String(a.key).localeCompare(String(b.key), 'de'),
)
return (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
{schemaList.map((s) => (
<div key={s.key} className="form-row">
<label className="form-label">
{s.name_de}
{s.required ? ' *' : ''}
{s.unit ? ` (${s.unit})` : ''}
</label>
{s.data_type === 'boolean' ? (
<input
type="checkbox"
style={{ width: 'auto', marginRight: 'auto' }}
checked={!!values[s.key]}
onChange={(e) => set(s.key, e.target.checked)}
/>
) : s.data_type === 'integer' || s.data_type === 'float' ? (
<input
type="number"
className="form-input"
step={s.data_type === 'integer' ? 1 : 'any'}
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
) : (
<input
type="text"
className="form-input"
value={values[s.key] ?? ''}
onChange={(e) => set(s.key, e.target.value)}
/>
)}
<span className="form-unit" />
</div>
))}
{profileFieldNodes}
{orphanMetrics.length > 0 && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
@ -195,7 +328,7 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
in activity_log) nicht ins Schema passen nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
Datenbank stehen.
</div>
{orphanMetrics.map((row) => {
{orphansSorted.map((row) => {
const disp =
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
? '—'
@ -421,6 +554,12 @@ export default function ActivityPage() {
const [categories, setCategories] = useState({}) // v9d: Training categories
const [sessionDetail, setSessionDetail] = useState(null)
const [metricDraft, setMetricDraft] = useState({})
/** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */
const editSchemaKeysPrevRef = useRef(new Set())
const prevEditingIdRef = useRef(null)
const [manualSchema, setManualSchema] = useState(null)
const [manualMetricDraft, setManualMetricDraft] = useState({})
const manualSchemaKeysPrevRef = useRef(new Set())
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false)
@ -506,18 +645,31 @@ export default function ActivityPage() {
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [fetchMonthsChain])
useEffect(() => {
editSchemaKeysPrevRef.current = new Set()
}, [editing?.id])
useEffect(() => {
if (!editing?.id) {
setSessionDetail(null)
setMetricDraft({})
setSessionLoadError(null)
prevEditingIdRef.current = null
return
}
let cancelled = false
setSessionLoadError(null)
if (prevEditingIdRef.current !== editing.id) {
setSessionDetail(null)
prevEditingIdRef.current = editing.id
}
;(async () => {
try {
const d = await api.getActivitySession(editing.id)
const d = await api.getActivitySession(editing.id, {
useFormSchema: true,
training_category: editing.training_category,
training_type_id: editing.training_type_id,
})
if (!cancelled) setSessionDetail(d)
} catch (err) {
if (!cancelled) {
@ -527,25 +679,89 @@ export default function ActivityPage() {
}
})()
return () => { cancelled = true }
}, [editing?.id])
}, [editing?.id, editing?.training_category, editing?.training_type_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 : ''
const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key))
const oldKeys = editSchemaKeysPrevRef.current
setMetricDraft((prev) => {
const next = { ...prev }
for (const row of sessionDetail.metrics || []) {
const k = row.key
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
continue
}
next[k] = row.value
}
}
setMetricDraft(m)
for (const s of sessionDetail.schema || []) {
if (!(s.key in next)) {
next[s.key] = s.data_type === 'boolean' ? false : ''
}
}
return next
})
editSchemaKeysPrevRef.current = newKeys
}, [sessionDetail])
useEffect(() => {
if (tab !== 'add') {
setManualSchema(null)
setManualMetricDraft({})
manualSchemaKeysPrevRef.current = new Set()
return
}
const tid = form.training_type_id
const cat = form.training_category
if (tid == null && (cat == null || cat === '')) {
setManualSchema(null)
setManualMetricDraft({})
manualSchemaKeysPrevRef.current = new Set()
return
}
let cancelled = false
;(async () => {
try {
const r = await api.getActivityAttributeSchema({
training_category: cat || undefined,
training_type_id: tid ?? undefined,
})
if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : [])
} catch (err) {
console.error('attribute-schema:', err)
if (!cancelled) setManualSchema([])
}
})()
return () => { cancelled = true }
}, [tab, form.training_category, form.training_type_id])
useEffect(() => {
if (tab !== 'add' || !manualSchema) {
return
}
const newKeys = new Set(manualSchema.map((s) => s.key))
const oldKeys = manualSchemaKeysPrevRef.current
setManualMetricDraft((prev) => {
const next = { ...prev }
for (const s of manualSchema) {
const k = s.key
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
continue
}
if (!(k in next)) {
next[k] = s.data_type === 'boolean' ? false : ''
}
}
return next
})
manualSchemaKeysPrevRef.current = newKeys
}, [tab, manualSchema])
const handleSave = async () => {
setSaving(true)
setError(null)
@ -565,7 +781,20 @@ export default function ActivityPage() {
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
payload.source = 'manual'
await api.createActivity(payload)
const created = await api.createActivity(payload)
if (manualSchema && manualSchema.length > 0 && created?.id) {
try {
const metrics = buildMetricsPayload(manualSchema, manualMetricDraft)
await api.putActivityMetrics(created.id, { metrics })
} catch (metErr) {
console.error(metErr)
setError(
metErr.message ||
'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.',
)
setTimeout(() => setError(null), 8000)
}
}
setSaved(true)
await load()
await loadUsage() // Reload usage after save
@ -624,7 +853,26 @@ export default function ActivityPage() {
: timePayloadFromInput(payload.end_time)
await api.updateActivity(editing.id, payload)
if (sessionDetail?.schema?.length > 0) {
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
const draftForMetrics = { ...metricDraft }
for (const s of sessionDetail.schema) {
const bind = activitySchemaHeadlineBinding(s)
if (!bind || !(s.key in draftForMetrics)) continue
const rawCol =
payload[bind.headlineCol] !== undefined ? payload[bind.headlineCol] : editing?.[bind.headlineCol]
if (rawCol === undefined) continue
if (s.data_type === 'boolean') {
draftForMetrics[s.key] = !!rawCol
} else if (s.data_type === 'integer') {
const n = parseInt(String(rawCol), 10)
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
} else if (s.data_type === 'float') {
const n = parseFloat(String(rawCol))
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
} else {
draftForMetrics[s.key] = rawCol == null ? '' : String(rawCol)
}
}
const metrics = buildMetricsPayload(sessionDetail.schema, draftForMetrics)
await api.putActivityMetrics(editing.id, { metrics })
}
setEditing(null)
@ -712,9 +960,23 @@ export default function ActivityPage() {
<span>Training eintragen</span>
{activityUsage && <UsageBadge {...activityUsage} />}
</div>
<EntryForm form={form} setForm={setForm}
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
saving={saving} error={error} usage={activityUsage}/>
<EntryForm
form={form}
setForm={setForm}
onSave={handleSave}
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
saving={saving}
error={error}
usage={activityUsage}
formExtras={
<SessionMetricsFields
schema={manualSchema}
metrics={[]}
values={manualMetricDraft}
setValues={setManualMetricDraft}
/>
}
/>
</div>
)}

View File

@ -10,6 +10,8 @@ const emptyParamForm = () => ({
key: '',
name_de: '',
name_en: '',
description_de: '',
description_en: '',
category: 'physical',
data_type: 'float',
unit: '',
@ -130,6 +132,8 @@ export default function AdminActivityAttributeProfilesPage() {
key: paramForm.key.trim().toLowerCase(),
name_de: paramForm.name_de.trim(),
name_en: paramForm.name_en.trim(),
description_de: paramForm.description_de.trim() || null,
description_en: paramForm.description_en.trim() || null,
category: paramForm.category,
data_type: paramForm.data_type,
unit: paramForm.unit.trim() || null,
@ -153,6 +157,8 @@ export default function AdminActivityAttributeProfilesPage() {
await api.adminUpdateTrainingParameter(editParam.id, {
name_de: editParam.name_de.trim(),
name_en: editParam.name_en.trim(),
description_de: editParam.description_de?.trim() || null,
description_en: editParam.description_en?.trim() || null,
category: editParam.category,
data_type: editParam.data_type,
unit: editParam.unit?.trim() || null,
@ -273,7 +279,7 @@ export default function AdminActivityAttributeProfilesPage() {
}
return (
<div className="capture-page">
<div className="capture-page activity-attribute-profiles">
<div style={{ marginBottom: 12 }}>
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
Training (Hub)
@ -302,6 +308,11 @@ export default function AdminActivityAttributeProfilesPage() {
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
</li>
<li>
<strong>KI:</strong> Bei eigenen / unklaren Metriken kurze <strong>Beschreibung DE/EN</strong> im Katalog
pflegen sie erscheinen in Export/Platzhalter-Kontext (<code>training_sessions_recent_json</code>,{' '}
<code>{'{{training_parameters_glossary_md}}'}</code>).
</li>
</ul>
</div>
@ -367,70 +378,151 @@ export default function AdminActivityAttributeProfilesPage() {
style={{
border: '1px solid var(--border)',
borderRadius: 8,
padding: 12,
padding: 16,
marginBottom: 12,
background: 'var(--surface2)',
}}
>
<div className="form-row">
<label className="form-label">key</label>
<input
className="form-input"
placeholder="z. B. avg_power"
value={paramForm.key}
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">name_de / name_en</label>
<input
className="form-input"
value={paramForm.name_de}
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
/>
<input
className="form-input"
value={paramForm.name_en}
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Datentyp</label>
<select
className="form-input"
value={paramForm.category}
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="form-input"
value={paramForm.data_type}
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
>
{DATA_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Einheit / source_field</label>
<input
className="form-input"
value={paramForm.unit}
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
/>
<input
className="form-input"
value={paramForm.source_field}
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
/>
<div className="aaf-stack">
<div className="aaf-field">
<label className="aaf-label" htmlFor="aaf-new-key">
Technischer Schlüssel (key)
</label>
<input
id="aaf-new-key"
className="aaf-input"
placeholder="z. B. avg_power"
value={paramForm.key}
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
/>
</div>
<div className="aaf-field">
<span className="aaf-label">Bezeichnung</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-name-de">
Deutsch
</label>
<input
id="aaf-new-name-de"
className="aaf-input"
value={paramForm.name_de}
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
/>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-name-en">
English
</label>
<input
id="aaf-new-name-en"
className="aaf-input"
value={paramForm.name_en}
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
/>
</div>
</div>
</div>
<div className="aaf-field">
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-desc-de">
Deutsch
</label>
<textarea
id="aaf-new-desc-de"
className="aaf-input"
rows={3}
placeholder="Was bedeutet der Wert? Einheit, Skala, Herkunft …"
value={paramForm.description_de}
onChange={(e) => setParamForm((f) => ({ ...f, description_de: e.target.value }))}
/>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-desc-en">
English
</label>
<textarea
id="aaf-new-desc-en"
className="aaf-input"
rows={3}
placeholder="Short meaning for prompts and EN contexts"
value={paramForm.description_en}
onChange={(e) => setParamForm((f) => ({ ...f, description_en: e.target.value }))}
/>
</div>
</div>
</div>
<div className="aaf-field">
<span className="aaf-label">Gruppe und Datentyp</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-cat">
Parameter-Gruppe
</label>
<select
id="aaf-new-cat"
className="aaf-input"
value={paramForm.category}
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-new-dtype">
Datentyp
</label>
<select
id="aaf-new-dtype"
className="aaf-input"
value={paramForm.data_type}
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
>
{DATA_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
</div>
<div className="aaf-field">
<label className="aaf-label" htmlFor="aaf-new-unit">
Einheit (optional)
</label>
<input
id="aaf-new-unit"
className="aaf-input"
placeholder="z. B. W, bpm, min"
value={paramForm.unit}
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
/>
</div>
<div className="aaf-field">
<label className="aaf-label" htmlFor="aaf-new-source-field">
Quell-Spalte in activity_log (source_field, optional)
</label>
<input
id="aaf-new-source-field"
className="aaf-input"
placeholder="z. B. hr_avg — Spaltenname der Trainingseinheit"
autoComplete="off"
value={paramForm.source_field}
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
/>
<p className="aaf-hint">
Wenn gesetzt, wird der Messwert beim Anzeigen und Zusammenführen mit EAV primär aus dieser
Spalte der Einheit gelesen (nicht aus der EAV-Tabelle). Leer lassen, wenn der Wert nur über
EAV oder Standard-Spalten kommt.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
@ -448,63 +540,138 @@ export default function AdminActivityAttributeProfilesPage() {
style={{
border: '1px solid var(--accent)',
borderRadius: 8,
padding: 12,
padding: 16,
marginBottom: 12,
}}
>
<div className="card-title" style={{ fontSize: 14 }}>
Bearbeiten: <code>{editParam.key}</code>
</div>
<div className="form-row">
<label className="form-label">name_de / name_en</label>
<input
className="form-input"
value={editParam.name_de || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
/>
<input
className="form-input"
value={editParam.name_en || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
/>
</div>
<div className="form-row">
<label className="form-label">Gruppe / Typ</label>
<select
className="form-input"
value={editParam.category}
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
className="form-input"
value={editParam.data_type}
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
>
{DATA_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Einheit / source_field</label>
<input
className="form-input"
value={editParam.unit || ''}
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
/>
<input
className="form-input"
value={editParam.source_field || ''}
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
/>
<div className="aaf-stack">
<div className="aaf-field">
<span className="aaf-label">Bezeichnung</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-name-de">
Deutsch
</label>
<input
id="aaf-edit-name-de"
className="aaf-input"
value={editParam.name_de || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
/>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-name-en">
English
</label>
<input
id="aaf-edit-name-en"
className="aaf-input"
value={editParam.name_en || ''}
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
/>
</div>
</div>
</div>
<div className="aaf-field">
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-de">
Deutsch
</label>
<textarea
id="aaf-edit-desc-de"
className="aaf-input"
rows={3}
value={editParam.description_de || ''}
onChange={(e) => setEditParam((p) => ({ ...p, description_de: e.target.value }))}
/>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-en">
English
</label>
<textarea
id="aaf-edit-desc-en"
className="aaf-input"
rows={3}
value={editParam.description_en || ''}
onChange={(e) => setEditParam((p) => ({ ...p, description_en: e.target.value }))}
/>
</div>
</div>
</div>
<div className="aaf-field">
<span className="aaf-label">Gruppe und Datentyp</span>
<div className="aaf-split">
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-cat">
Parameter-Gruppe
</label>
<select
id="aaf-edit-cat"
className="aaf-input"
value={editParam.category}
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
>
{PARAM_GROUP.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="aaf-sublabel" htmlFor="aaf-edit-dtype">
Datentyp
</label>
<select
id="aaf-edit-dtype"
className="aaf-input"
value={editParam.data_type}
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
>
{DATA_TYPES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
</div>
</div>
<div className="aaf-field">
<label className="aaf-label" htmlFor="aaf-edit-unit">
Einheit (optional)
</label>
<input
id="aaf-edit-unit"
className="aaf-input"
placeholder="z. B. W, bpm, min"
value={editParam.unit || ''}
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
/>
</div>
<div className="aaf-field">
<label className="aaf-label" htmlFor="aaf-edit-source-field">
Quell-Spalte in activity_log (source_field, optional)
</label>
<input
id="aaf-edit-source-field"
className="aaf-input"
placeholder="z. B. hr_avg"
autoComplete="off"
value={editParam.source_field || ''}
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
/>
<p className="aaf-hint">
Optional: Name der <code>activity_log</code>-Spalte, aus der dieser Parameter beim Lesen zuerst
befüllt wird (kanonisch vor EAV). Leer, wenn nur EAV oder implizites Spalten-Mapping.
</p>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
<input
@ -582,13 +749,15 @@ export default function AdminActivityAttributeProfilesPage() {
{tab === 'category' && (
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
<div className="form-row">
<label className="form-label">Kategorie</label>
<div className="aaf-field-select">
<label className="form-label" htmlFor="aaf-cat-pick">
Kategorie
</label>
<select
id="aaf-cat-pick"
className="form-input"
value={selCategory}
onChange={(e) => setSelCategory(e.target.value)}
style={{ maxWidth: 280 }}
>
{categoryKeys.map((k) => (
<option key={k} value={k}>
@ -597,10 +766,13 @@ export default function AdminActivityAttributeProfilesPage() {
))}
</select>
</div>
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="form-label">Parameter</label>
<div className="aaf-toolbar">
<div className="aaf-toolbar__grow">
<label className="form-label" htmlFor="aaf-cat-param">
Parameter
</label>
<select
id="aaf-cat-param"
className="form-input"
value={catAdd.training_parameter_id}
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
@ -613,17 +785,22 @@ export default function AdminActivityAttributeProfilesPage() {
))}
</select>
</div>
<div>
<label className="form-label">sort</label>
<div className="aaf-toolbar__compact">
<label className="form-label" htmlFor="aaf-cat-sort">
Sortierung
</label>
<input
id="aaf-cat-sort"
type="number"
className="form-input"
style={{ width: 80 }}
value={catAdd.sort_order}
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<label
className="aaf-toolbar__compact"
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, paddingBottom: 4 }}
>
<input
type="checkbox"
checked={catAdd.required}
@ -631,11 +808,14 @@ export default function AdminActivityAttributeProfilesPage() {
/>
Pflicht
</label>
<div>
<label className="form-label">ui_group</label>
<div className="aaf-toolbar__compact">
<label className="form-label" htmlFor="aaf-cat-uigroup">
ui_group
</label>
<input
id="aaf-cat-uigroup"
className="form-input"
style={{ width: 120 }}
placeholder="optional"
value={catAdd.ui_group}
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
@ -655,21 +835,20 @@ export default function AdminActivityAttributeProfilesPage() {
}}
>
{editingCatId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
<div className="aaf-inline-edit" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
<span style={{ flex: '1 1 200px' }}>
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
</span>
<div>
<label className="form-label">sort</label>
<label className="form-label">Sortierung</label>
<input
type="number"
className="form-input"
style={{ width: 72 }}
value={catDraft.sort_order}
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
/>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, paddingBottom: 4 }}>
<input
type="checkbox"
checked={!!catDraft.required}
@ -679,7 +858,6 @@ export default function AdminActivityAttributeProfilesPage() {
</label>
<input
className="form-input"
style={{ width: 100 }}
placeholder="ui_group"
value={catDraft.ui_group}
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
@ -739,13 +917,15 @@ export default function AdminActivityAttributeProfilesPage() {
{tab === 'type' && (
<div className="card section-gap">
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
<div className="form-row">
<label className="form-label">Trainingstyp</label>
<div className="aaf-field-select">
<label className="form-label" htmlFor="aaf-type-pick">
Trainingstyp
</label>
<select
id="aaf-type-pick"
className="form-input"
value={selTypeId}
onChange={(e) => setSelTypeId(e.target.value)}
style={{ maxWidth: 420 }}
>
{flatTypes.map((t) => (
<option key={t.id} value={t.id}>
@ -754,10 +934,13 @@ export default function AdminActivityAttributeProfilesPage() {
))}
</select>
</div>
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<label className="form-label">Parameter</label>
<div className="aaf-toolbar">
<div className="aaf-toolbar__grow">
<label className="form-label" htmlFor="aaf-type-param">
Parameter
</label>
<select
id="aaf-type-param"
className="form-input"
value={typeAdd.training_parameter_id}
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
@ -770,21 +953,25 @@ export default function AdminActivityAttributeProfilesPage() {
))}
</select>
</div>
<div>
<label className="form-label">sort (leer=Erben)</label>
<div className="aaf-toolbar__compact">
<label className="form-label" htmlFor="aaf-type-sort">
Sortierung (leer = erben)
</label>
<input
id="aaf-type-sort"
type="number"
className="form-input"
style={{ width: 80 }}
value={typeAdd.sort_order}
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
/>
</div>
<div>
<label className="form-label">Pflicht (leer=Erben)</label>
<div className="aaf-toolbar__compact">
<label className="form-label" htmlFor="aaf-type-req">
Pflicht (leer = erben)
</label>
<select
id="aaf-type-req"
className="form-input"
style={{ width: 100 }}
value={typeAdd.required}
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
>
@ -793,11 +980,14 @@ export default function AdminActivityAttributeProfilesPage() {
<option value="false">nein</option>
</select>
</div>
<div>
<label className="form-label">ui_group</label>
<div className="aaf-toolbar__compact">
<label className="form-label" htmlFor="aaf-type-uigroup">
ui_group
</label>
<input
id="aaf-type-uigroup"
className="form-input"
style={{ width: 120 }}
placeholder="optional"
value={typeAdd.ui_group}
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
@ -817,23 +1007,21 @@ export default function AdminActivityAttributeProfilesPage() {
}}
>
{editingTypeId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
<div className="aaf-inline-edit" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
<span style={{ flex: '1 1 200px' }}>
<strong>{l.parameter_key}</strong>
</span>
<div>
<label className="form-label">sort</label>
<label className="form-label">Sortierung</label>
<input
type="number"
className="form-input"
style={{ width: 72 }}
value={typeDraft.sort_order}
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
/>
</div>
<select
className="form-input"
style={{ width: 100 }}
value={typeDraft.required}
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
>

View File

@ -352,7 +352,39 @@ export const api = {
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
/**
* @param {string} id
* @param {{ useFormSchema?: boolean, training_category?: string | null, training_type_id?: number | null }} [opts]
*/
getActivitySession: (id, opts = {}) => {
const q = new URLSearchParams()
if (opts.useFormSchema) {
q.set('use_form_schema', 'true')
if (opts.training_category != null && opts.training_category !== '') {
q.set('training_category', String(opts.training_category))
}
if (opts.training_type_id != null && opts.training_type_id !== '') {
q.set('training_type_id', String(opts.training_type_id))
}
}
const qs = q.toString()
return req(`/activity/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`)
},
/**
* Attributprofil ohne Session (manuelle Erfassung / Vorschau).
* @param {{ training_category?: string | null, training_type_id?: number | null }} [params]
*/
getActivityAttributeSchema: (params = {}) => {
const q = new URLSearchParams()
if (params.training_category != null && params.training_category !== '') {
q.set('training_category', String(params.training_category))
}
if (params.training_type_id != null && params.training_type_id !== '') {
q.set('training_type_id', String(params.training_type_id))
}
const qs = q.toString()
return req(`/activity/attribute-schema${qs ? `?${qs}` : ''}`)
},
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),