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 1–2) + **Phasenplan A–F** 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.3–2.4, Phasen D–E), `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). **7–8** 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 3–5 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 A–B** 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 A–F
- `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 **Layer‑1‑Quelle** (`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 **Layer‑2a**-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 Layer‑1-/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 Layer‑1‑Berechnungen **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` (A1–A8) | Spalten-Aggregate | bewusst: Dauer/RPE/HF aus **`activity_log`**-Kanon; kein EAV-Join nötig für definierte Charts |
| `activity_stats` (`GET /api/activity/stats`) | nur Spalten | Kacheln: `kcal`/`duration` aus Kernspalten |
---
### 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 A–F
- `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 A–F.
**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 A–F** 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 **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance).

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 (1–10)"},
"pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"},
"cadence": {"type": "int", "required": False, "label_de": "Kadenz"},
"avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"},
"elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"},
"temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"},
"humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"},
"avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"},
"kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"},
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
},
"derive_date_from_datetime_field": "start_time",

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, Spalten→EAV).
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)),