Erste Version Platzhalter EAV #86
|
|
@ -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) |
|
| 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 |
|
| 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` |
|
| 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 |
|
| 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 |
|
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
| `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_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) |
|
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
70
.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md
Normal file
70
.claude/docs/technical/ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md
Normal 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)
|
||||||
|
|
@ -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.
|
||||||
95
.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md
Normal file
95
.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md
Normal 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
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
**Status:** Kern-Backend (Migration 054, Layer 1, Admin- & Nutzer-API) umgesetzt; Admin-UI & CSV-Mapping folgen.
|
**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.
|
**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)
|
## 1. Produktions-Migrationen (Pflicht)
|
||||||
|
|
@ -41,7 +47,9 @@
|
||||||
|
|
||||||
| Modul | Pfad | Aufgabe |
|
| 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:**
|
**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`).
|
- 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`.
|
- 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)
|
## 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)
|
## 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] 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`.
|
- [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).
|
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
|
||||||
- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`.
|
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
|
||||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
- [ ] 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
|
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
|
||||||
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
||||||
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
|
- 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}}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
1480
.claude/docs/technical/functional_concept_composite_data.md
Normal file
1480
.claude/docs/technical/functional_concept_composite_data.md
Normal file
File diff suppressed because it is too large
Load Diff
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -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`).
|
- **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).
|
- **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.
|
- **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)
|
### 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).
|
- **`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).
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
"activity": {
|
||||||
"table": "activity_log",
|
"table": "activity_log",
|
||||||
"fields": {
|
"fields": {
|
||||||
|
|
@ -63,16 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||||
"max": 220,
|
"max": 220,
|
||||||
"label_de": "Herzfrequenz max (bpm)",
|
"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)"},
|
"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"},
|
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
||||||
},
|
},
|
||||||
"derive_date_from_datetime_field": "start_time",
|
"derive_date_from_datetime_field": "start_time",
|
||||||
|
|
|
||||||
61
backend/data_layer/activity_data_canon.py
Normal file
61
backend/data_layer/activity_data_canon.py
Normal 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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ Functions:
|
||||||
- get_training_frequency_by_type_data(): Häufigkeit & Intensität pro activity_type
|
- 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_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
||||||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
- 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.
|
All functions return structured data (dict) without formatting.
|
||||||
Use placeholder_resolver.py for formatted strings for AI.
|
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"},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 models import ActivityEntry
|
||||||
|
|
||||||
from csv_parser.module_registry import get_module_definition
|
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__)
|
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]:
|
def activity_registry_field_keys() -> frozenset[str]:
|
||||||
mod = get_module_definition("activity")
|
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
|
||||||
if not mod:
|
return get_activity_module_registry_field_keys()
|
||||||
return frozenset()
|
|
||||||
return frozenset((mod.get("fields") or {}).keys())
|
|
||||||
|
|
||||||
|
|
||||||
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
|
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:
|
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:
|
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
|
||||||
cur.execute(
|
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)
|
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||||
except Exception as eval_error:
|
except Exception as eval_error:
|
||||||
logger.error("[AUTO-EVAL] activity %s: %s", eid, 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(
|
def run_activity_post_write_hooks_import(
|
||||||
|
|
@ -286,7 +283,7 @@ def run_activity_post_write_hooks_import(
|
||||||
kcal_active: Any,
|
kcal_active: Any,
|
||||||
kcal_resting: Any,
|
kcal_resting: Any,
|
||||||
) -> None:
|
) -> 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:
|
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||||
try:
|
try:
|
||||||
activity_dict = {
|
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)
|
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||||
except Exception as eval_err:
|
except Exception as eval_err:
|
||||||
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", 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(
|
def merge_activity_csv_module_fields(
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,13 @@ import logging
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
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__)
|
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).
|
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
|
||||||
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
||||||
{
|
{
|
||||||
|
|
@ -95,6 +71,8 @@ def merge_parameter_schema_rows(
|
||||||
"key": r["key"],
|
"key": r["key"],
|
||||||
"name_de": r["name_de"],
|
"name_de": r["name_de"],
|
||||||
"name_en": r["name_en"],
|
"name_en": r["name_en"],
|
||||||
|
"description_de": r.get("description_de"),
|
||||||
|
"description_en": r.get("description_en"),
|
||||||
"param_category": r["param_category"],
|
"param_category": r["param_category"],
|
||||||
"data_type": r["data_type"],
|
"data_type": r["data_type"],
|
||||||
"unit": r["unit"],
|
"unit": r["unit"],
|
||||||
|
|
@ -114,6 +92,8 @@ def merge_parameter_schema_rows(
|
||||||
"key": r["key"],
|
"key": r["key"],
|
||||||
"name_de": r["name_de"],
|
"name_de": r["name_de"],
|
||||||
"name_en": r["name_en"],
|
"name_en": r["name_en"],
|
||||||
|
"description_de": r.get("description_de"),
|
||||||
|
"description_en": r.get("description_en"),
|
||||||
"param_category": r["param_category"],
|
"param_category": r["param_category"],
|
||||||
"data_type": r["data_type"],
|
"data_type": r["data_type"],
|
||||||
"unit": r["unit"],
|
"unit": r["unit"],
|
||||||
|
|
@ -157,7 +137,9 @@ def resolve_activity_attribute_schema(
|
||||||
tcp.sort_order AS cat_sort,
|
tcp.sort_order AS cat_sort,
|
||||||
tcp.required AS cat_required,
|
tcp.required AS cat_required,
|
||||||
tcp.ui_group AS cat_ui_group,
|
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
|
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
||||||
FROM training_category_parameter tcp
|
FROM training_category_parameter tcp
|
||||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
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.sort_order AS typ_sort,
|
||||||
ttp.required AS typ_required,
|
ttp.required AS typ_required,
|
||||||
ttp.ui_group AS typ_ui_group,
|
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
|
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
||||||
FROM training_type_parameter ttp
|
FROM training_type_parameter ttp
|
||||||
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
|
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)
|
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]:
|
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
return raw
|
return raw
|
||||||
|
|
@ -276,20 +270,26 @@ def upsert_session_metrics_from_csv_mapped(
|
||||||
training_type_id: Optional[int],
|
training_type_id: Optional[int],
|
||||||
) -> None:
|
) -> 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;
|
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
|
||||||
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
|
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(
|
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||||
"SELECT profile_id FROM activity_log WHERE id = %s",
|
|
||||||
(activity_log_id,),
|
|
||||||
)
|
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
if not row or str(row["profile_id"]) != str(profile_id):
|
||||||
return
|
return
|
||||||
mod = get_module_definition("activity") or {}
|
header = dict(row)
|
||||||
activity_registry_keys = frozenset((mod.get("fields") or {}).keys())
|
|
||||||
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
||||||
for spec in schema:
|
for spec in schema:
|
||||||
pkey = spec["key"]
|
pkey = spec["key"]
|
||||||
|
|
@ -298,8 +298,13 @@ def upsert_session_metrics_from_csv_mapped(
|
||||||
raw = mapped[pkey]
|
raw = mapped[pkey]
|
||||||
if raw is None or raw == "":
|
if raw is None or raw == "":
|
||||||
continue
|
continue
|
||||||
if pkey in activity_registry_keys:
|
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
|
||||||
continue
|
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"]
|
tid = spec["training_parameter_id"]
|
||||||
dt = spec["data_type"]
|
dt = spec["data_type"]
|
||||||
rules = _validation_rules_dict(spec["validation_rules"])
|
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:
|
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
|
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
|
||||||
schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit.
|
|
||||||
|
|
||||||
Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben
|
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
|
||||||
Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen.
|
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,))
|
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
@ -516,47 +621,37 @@ def replace_activity_session_metrics(
|
||||||
return fetch_activity_session_metrics(cur, activity_log_id)
|
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,))
|
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row or str(row["profile_id"]) != str(profile_id):
|
if not row or str(row["profile_id"]) != str(profile_id):
|
||||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
||||||
|
|
||||||
header = dict(row)
|
header = dict(row)
|
||||||
schema = resolve_activity_attribute_schema(
|
if use_form_training_context:
|
||||||
cur, header.get("training_category"), header.get("training_type_id")
|
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)
|
metrics = fetch_activity_session_metrics(cur, activity_log_id)
|
||||||
by_key = {m["key"]: m for m in metrics}
|
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
|
||||||
merged_metrics: List[Dict[str, Any]] = list(metrics)
|
|
||||||
for s in schema:
|
|
||||||
k = s["key"]
|
|
||||||
if k in by_key:
|
|
||||||
continue
|
|
||||||
sf = s.get("source_field")
|
|
||||||
if not sf or (isinstance(sf, str) and not str(sf).strip()):
|
|
||||||
continue
|
|
||||||
col = str(sf).strip()
|
|
||||||
if col not in header:
|
|
||||||
continue
|
|
||||||
raw = header.get(col)
|
|
||||||
if raw is None:
|
|
||||||
continue
|
|
||||||
dt = s["data_type"]
|
|
||||||
try:
|
|
||||||
val = _coerce_raw_value_for_parameter(dt, raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
merged_metrics.append(
|
|
||||||
{
|
|
||||||
"training_parameter_id": s["training_parameter_id"],
|
|
||||||
"key": k,
|
|
||||||
"data_type": dt,
|
|
||||||
"unit": s.get("unit"),
|
|
||||||
"value": val,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
merged_metrics.sort(key=lambda x: x["key"])
|
|
||||||
return {
|
return {
|
||||||
"header": header,
|
"header": header,
|
||||||
"schema": schema,
|
"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:
|
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:
|
if not sessions:
|
||||||
return
|
return
|
||||||
ids = [str(s["id"]) for s in sessions if s.get("id")]
|
ids = [str(s["id"]) for s in sessions if s.get("id")]
|
||||||
if not ids:
|
if not ids:
|
||||||
return
|
return
|
||||||
ph = ",".join(["%s"] * len(ids))
|
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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
m.activity_log_id,
|
m.activity_log_id,
|
||||||
|
m.training_parameter_id,
|
||||||
tp.key,
|
tp.key,
|
||||||
tp.data_type,
|
tp.data_type,
|
||||||
tp.unit,
|
tp.unit,
|
||||||
|
|
@ -603,8 +714,42 @@ def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
|
||||||
else:
|
else:
|
||||||
val = r["value_bool"]
|
val = r["value_bool"]
|
||||||
by_act.setdefault(aid, []).append(
|
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:
|
for s in sessions:
|
||||||
aid = str(s.get("id"))
|
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
|
||||||
|
]
|
||||||
|
|
|
||||||
115
backend/migrations/057_activity_eav_primary_canon.sql
Normal file
115
backend/migrations/057_activity_eav_primary_canon.sql
Normal 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 $$;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Activity Metrics Placeholder Registrations
|
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.
|
Evidence-based metadata with clear tagging of source.
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ def register_activity_group_1():
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="_format_activity_summary",
|
resolver_function="get_activity_summary",
|
||||||
data_layer_module=None,
|
data_layer_module=None,
|
||||||
data_layer_function=None,
|
data_layer_function=None,
|
||||||
source_tables=["activity_log", "training_types"],
|
source_tables=["activity_log", "training_types"],
|
||||||
|
|
@ -127,17 +127,23 @@ def register_activity_group_1():
|
||||||
activity_detail_metadata = PlaceholderMetadata(
|
activity_detail_metadata = PlaceholderMetadata(
|
||||||
key="activity_detail",
|
key="activity_detail",
|
||||||
category="Aktivität",
|
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_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="get_activity_detail",
|
resolver_function="get_activity_detail",
|
||||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||||
data_layer_function="get_activity_detail_data",
|
data_layer_function="get_activity_detail_data",
|
||||||
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Liefert bis zu 50 Einheiten (neueste zuerst) der letzten 14 Tage über "
|
"Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
|
||||||
"get_activity_detail_data: activity_log-Spalten plus "
|
"session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
|
||||||
"enrich_sessions_with_metrics (activity_session_metrics / Profil-EAV). "
|
"Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
|
||||||
"Formatter hängt nicht-leere EAV-Werte als „| EAV: key=value; …“ an."
|
"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=(
|
business_meaning=(
|
||||||
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
||||||
|
|
@ -167,8 +173,10 @@ def register_activity_group_1():
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
|
"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 "
|
"(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
|
||||||
"in EAV wiederholt erscheinen — KI kann dominante Spalte nutzen."
|
"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_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
|
||||||
layer_2a_decision="get_activity_detail (Formatierung)",
|
layer_2a_decision="get_activity_detail (Formatierung)",
|
||||||
|
|
@ -211,56 +219,47 @@ def register_activity_group_1():
|
||||||
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
||||||
key="trainingstyp_verteilung",
|
key="trainingstyp_verteilung",
|
||||||
category="Aktivität",
|
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_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="_format_trainingstyp_verteilung",
|
resolver_function="get_trainingstyp_verteilung",
|
||||||
data_layer_module=None,
|
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||||
data_layer_function=None,
|
data_layer_function="get_training_type_distribution_data",
|
||||||
source_tables=["activity_log", "training_types"],
|
source_tables=["activity_log"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. "
|
"Layer 1: get_training_type_distribution_data — Anteil je training_category am "
|
||||||
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), "
|
"Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
|
||||||
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend."
|
"Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
|
||||||
),
|
),
|
||||||
business_meaning=(
|
business_meaning=(
|
||||||
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
||||||
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
||||||
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
||||||
),
|
),
|
||||||
unit="json",
|
unit="text",
|
||||||
time_window="14d",
|
time_window="14d",
|
||||||
output_type=OutputType.JSON,
|
output_type=OutputType.TEXT_SUMMARY,
|
||||||
placeholder_type=PlaceholderType.INTERPRETED,
|
placeholder_type=PlaceholderType.INTERPRETED,
|
||||||
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}",
|
format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
|
||||||
example_output=(
|
example_output="cardio: 45%, strength: 30%, mobility: 15%",
|
||||||
'{"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}}'
|
|
||||||
),
|
|
||||||
minimum_data_requirements=None,
|
minimum_data_requirements=None,
|
||||||
quality_filter_policy=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(
|
missing_value_policy=MissingValuePolicy(
|
||||||
available=False,
|
available=False,
|
||||||
value_raw=None,
|
value_raw=None,
|
||||||
missing_reason="no_data",
|
missing_reason="no_data",
|
||||||
legacy_display="{}"
|
legacy_display="Keine kategorisierten Trainings"
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
"Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
|
||||||
"Aggregation direkt im Resolver. "
|
"Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
|
||||||
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. "
|
"Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
|
||||||
"JOIN mit training_types für Typ-Namen. "
|
|
||||||
"EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)."
|
|
||||||
),
|
),
|
||||||
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)",
|
layer_1_decision="activity_metrics.get_training_type_distribution_data",
|
||||||
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)",
|
layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
|
||||||
layer_2b_reuse_possible=True,
|
layer_2b_reuse_possible=True,
|
||||||
architecture_alignment=(
|
architecture_alignment="Phase 0c — Layer 1 + Formatierung",
|
||||||
"PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, "
|
issue_53_alignment="Layer 1"
|
||||||
"but no data layer separation. Should be refactored."
|
|
||||||
),
|
|
||||||
issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,8 @@ def register_activity_session_insights():
|
||||||
key="training_sessions_recent_json",
|
key="training_sessions_recent_json",
|
||||||
category="Aktivität",
|
category="Aktivität",
|
||||||
description=(
|
description=(
|
||||||
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, "
|
"JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics[] — gemergte Profil-Metriken "
|
||||||
"session_id, session_metrics[] aus EAV)"
|
"(dynamische Keys)"
|
||||||
),
|
),
|
||||||
resolver_module="backend/placeholder_resolver.py",
|
resolver_module="backend/placeholder_resolver.py",
|
||||||
resolver_function="_safe_json",
|
resolver_function="_safe_json",
|
||||||
|
|
@ -139,9 +139,16 @@ def register_activity_session_insights():
|
||||||
data_layer_function="get_training_sessions_recent_weeks_data",
|
data_layer_function="get_training_sessions_recent_weeks_data",
|
||||||
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
|
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
|
||||||
semantic_contract=(
|
semantic_contract=(
|
||||||
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung; "
|
"Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
|
||||||
"session_metrics[] = Layer-1-EAV-Werte (key, data_type, unit, value) wenn konfiguriert/gespeichert. "
|
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
|
||||||
"Default 4 ISO-Wochen zurück."
|
"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",
|
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
||||||
unit="JSON string",
|
unit="JSON string",
|
||||||
|
|
@ -160,8 +167,12 @@ def register_activity_session_insights():
|
||||||
legacy_display="{}",
|
legacy_display="{}",
|
||||||
),
|
),
|
||||||
known_limitations=(
|
known_limitations=(
|
||||||
"Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id. "
|
"Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. "
|
||||||
"session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind."
|
"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_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
|
||||||
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
||||||
|
|
@ -183,5 +194,61 @@ def register_activity_session_insights():
|
||||||
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
||||||
register_placeholder(pj)
|
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()
|
register_activity_session_insights()
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ from data_layer.activity_metrics import (
|
||||||
get_training_frequency_by_type_data,
|
get_training_frequency_by_type_data,
|
||||||
get_training_inter_session_gap_data,
|
get_training_inter_session_gap_data,
|
||||||
get_training_sessions_recent_weeks_data,
|
get_training_sessions_recent_weeks_data,
|
||||||
|
get_training_parameters_ki_glossary_data,
|
||||||
)
|
)
|
||||||
from data_layer.recovery_metrics import (
|
from data_layer.recovery_metrics import (
|
||||||
get_sleep_duration_data,
|
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")
|
k, v = m.get("key"), m.get("value")
|
||||||
if k is None or v is None:
|
if k is None or v is None:
|
||||||
continue
|
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 ""
|
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
|
||||||
lines.append(
|
lines.append(
|
||||||
f"{activity['date']}: {activity['activity_type']} "
|
f"{activity['date']}: {activity['activity_type']} "
|
||||||
|
|
@ -456,6 +458,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
||||||
return ", ".join(parts)
|
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:
|
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.
|
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),
|
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
|
||||||
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', 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_summary}}': get_activity_summary,
|
||||||
'{{activity_detail}}': get_activity_detail,
|
'{{activity_detail}}': get_activity_detail,
|
||||||
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
'{{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_frequency_by_type_md}}': get_training_frequency_by_type_md,
|
||||||
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_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_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)
|
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
|
||||||
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
|
'{{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}}',
|
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
|
||||||
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
||||||
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
||||||
|
'{{training_parameters_glossary_md}}',
|
||||||
],
|
],
|
||||||
'schlaf': [
|
'schlaf': [
|
||||||
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from data_layer.activity_persistence_orchestrator import (
|
||||||
new_activity_id,
|
new_activity_id,
|
||||||
)
|
)
|
||||||
from data_layer.activity_time_normalize import normalize_activity_start
|
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"])
|
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -71,6 +72,12 @@ def _activity_rows_after_list_query(cur):
|
||||||
return rows
|
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)
|
# Evaluation import with error handling (Phase 1.2)
|
||||||
try:
|
try:
|
||||||
from evaluation_helper import evaluate_and_save_activity
|
from evaluation_helper import evaluate_and_save_activity
|
||||||
|
|
@ -140,7 +147,7 @@ def list_activity(
|
||||||
""",
|
""",
|
||||||
(pid, d0, d1, limit),
|
(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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
|
|
@ -152,7 +159,9 @@ def list_activity(
|
||||||
""",
|
""",
|
||||||
(pid, d0, d1, limit),
|
(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 days is not None:
|
||||||
if collapse_duplicate_sessions:
|
if collapse_duplicate_sessions:
|
||||||
|
|
@ -173,7 +182,7 @@ def list_activity(
|
||||||
""",
|
""",
|
||||||
(pid, days, limit, offset),
|
(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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
|
|
@ -203,7 +212,7 @@ def list_activity(
|
||||||
""",
|
""",
|
||||||
(pid, limit, offset),
|
(pid, limit, offset),
|
||||||
)
|
)
|
||||||
return _activity_rows_after_list_query(cur)
|
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
|
|
@ -214,7 +223,7 @@ def list_activity(
|
||||||
""",
|
""",
|
||||||
(pid, limit, offset),
|
(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("")
|
@router.post("")
|
||||||
|
|
@ -362,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)):
|
||||||
return get_mappable_activity_field_catalog(cur, pid)
|
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}")
|
@router.put("/{eid}")
|
||||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||||
"""Update existing activity entry."""
|
"""Update existing activity entry."""
|
||||||
|
|
@ -413,6 +441,12 @@ def replace_activity_metrics(
|
||||||
@router.get("/{eid}")
|
@router.get("/{eid}")
|
||||||
def get_activity_session(
|
def get_activity_session(
|
||||||
eid: str,
|
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: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
||||||
|
|
@ -426,7 +460,14 @@ def get_activity_session(
|
||||||
try:
|
try:
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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:
|
except ActivitySessionMetricsError as err:
|
||||||
raise HTTPException(err.status_code, err.detail) from err
|
raise HTTPException(err.status_code, err.detail) from err
|
||||||
unit["header"] = serialize_dates(unit["header"])
|
unit["header"] = serialize_dates(unit["header"])
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
from routers.profiles import get_pid
|
from routers.profiles import get_pid
|
||||||
from feature_logger import log_feature_usage
|
from feature_logger import log_feature_usage
|
||||||
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
|
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"])
|
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||||
logger = logging.getLogger(__name__)
|
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():
|
for r in cur.fetchall():
|
||||||
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
||||||
|
|
||||||
# Activity
|
# Activity (Layer-1: gemergte session_metrics in Details)
|
||||||
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
cur.execute(
|
||||||
for r in cur.fetchall():
|
"SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date",
|
||||||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
(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)
|
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()]
|
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
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,))
|
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
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"
|
Einstellungen → Import → "Mitai Backup importieren"
|
||||||
wieder eingespielt werden.
|
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):
|
Format-Version 2 (ab v9b):
|
||||||
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
||||||
Trennzeichen: Semikolon (;)
|
Trennzeichen: Semikolon (;)
|
||||||
|
|
@ -318,13 +338,41 @@ Datumsformat: YYYY-MM-DD
|
||||||
r['fiber'] = None; r['note'] = ''
|
r['fiber'] = None; r['note'] = ''
|
||||||
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
|
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()]
|
rows = [r2d(r) for r in cur.fetchall()]
|
||||||
|
enrich_sessions_with_metrics(cur, rows)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None)
|
sm = r.pop("session_metrics", None) or []
|
||||||
r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None)
|
r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str)
|
||||||
r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None)
|
r["name"] = r["activity_type"]
|
||||||
write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
|
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
|
# 8. insights/ai_insights.json
|
||||||
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||||
|
|
|
||||||
179
backend/scripts/inspect_activity_eav.py
Normal file
179
backend/scripts/inspect_activity_eav.py
Normal 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()
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
|
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from data_layer.activity_session_metrics import (
|
from data_layer.activity_session_metrics import (
|
||||||
ActivitySessionMetricsError,
|
ActivitySessionMetricsError,
|
||||||
enrich_sessions_with_metrics,
|
enrich_sessions_with_metrics,
|
||||||
|
merge_column_backed_and_eav_metrics,
|
||||||
merge_parameter_schema_rows,
|
merge_parameter_schema_rows,
|
||||||
resolve_activity_attribute_schema,
|
resolve_activity_attribute_schema,
|
||||||
|
upsert_session_metrics_from_csv_mapped,
|
||||||
_row_value_tuple,
|
_row_value_tuple,
|
||||||
_validate_single_value,
|
_validate_single_value,
|
||||||
)
|
)
|
||||||
|
|
@ -94,6 +97,52 @@ def test_merge_type_overrides_required_and_sort():
|
||||||
assert merged[0]["required"] is True
|
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():
|
def test_merge_type_adds_parameter_not_in_category():
|
||||||
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
|
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
|
||||||
merged = merge_parameter_schema_rows([], typ)
|
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,)
|
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())
|
aid = str(uuid.uuid4())
|
||||||
bid = str(uuid.uuid4())
|
bid = str(uuid.uuid4())
|
||||||
|
|
||||||
class _Cur:
|
class _Cur:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.params = None
|
self.params = None
|
||||||
|
self._fetch_n = 0
|
||||||
|
|
||||||
def execute(self, sql, params=None):
|
def execute(self, sql, params=None):
|
||||||
self.sql = sql
|
self.sql = sql
|
||||||
self.params = params
|
self.params = params
|
||||||
|
|
||||||
def fetchall(self):
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"activity_log_id": uuid.UUID(aid),
|
"activity_log_id": uuid.UUID(aid),
|
||||||
|
"training_parameter_id": 3,
|
||||||
"key": "rpe",
|
"key": "rpe",
|
||||||
"data_type": "integer",
|
"data_type": "integer",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
|
|
@ -199,6 +265,280 @@ def test_enrich_sessions_batch():
|
||||||
|
|
||||||
sessions = [{"id": aid}, {"id": bid}]
|
sessions = [{"id": aid}, {"id": bid}]
|
||||||
enrich_sessions_with_metrics(_Cur(), sessions)
|
enrich_sessions_with_metrics(_Cur(), sessions)
|
||||||
assert sessions[0]["session_metrics"][0]["value"] == 7
|
assert sessions[0]["session_metrics"] == []
|
||||||
assert sessions[0]["session_metrics"][0]["key"] == "rpe"
|
|
||||||
assert sessions[1]["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
|
||||||
|
|
|
||||||
|
|
@ -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) */
|
/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */
|
||||||
.capture-shell {
|
.capture-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,77 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
|
||||||
'training_subcategory',
|
'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() {
|
function empty() {
|
||||||
return {
|
return {
|
||||||
date: dayjs().format('YYYY-MM-DD'),
|
date: dayjs().format('YYYY-MM-DD'),
|
||||||
|
|
@ -146,48 +217,110 @@ function buildMetricsPayload(schema, draft) {
|
||||||
|
|
||||||
function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||||
const schemaList = Array.isArray(schema) ? schema : []
|
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 metricRows = Array.isArray(metrics) ? metrics : []
|
||||||
const schemaKeys = new Set(schemaList.map((s) => s.key))
|
const schemaKeys = new Set(schemaForDisplay.map((s) => s.key))
|
||||||
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.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 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 (
|
return (
|
||||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||||||
{schemaList.map((s) => (
|
{profileFieldNodes}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
{orphanMetrics.length > 0 && (
|
{orphanMetrics.length > 0 && (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
<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
|
in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
|
||||||
Datenbank stehen.
|
Datenbank stehen.
|
||||||
</div>
|
</div>
|
||||||
{orphanMetrics.map((row) => {
|
{orphansSorted.map((row) => {
|
||||||
const disp =
|
const disp =
|
||||||
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
|
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 [categories, setCategories] = useState({}) // v9d: Training categories
|
||||||
const [sessionDetail, setSessionDetail] = useState(null)
|
const [sessionDetail, setSessionDetail] = useState(null)
|
||||||
const [metricDraft, setMetricDraft] = useState({})
|
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 [sessionLoadError, setSessionLoadError] = useState(null)
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
const [listLoadingMore, setListLoadingMore] = 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))
|
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||||
}, [fetchMonthsChain])
|
}, [fetchMonthsChain])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editSchemaKeysPrevRef.current = new Set()
|
||||||
|
}, [editing?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing?.id) {
|
if (!editing?.id) {
|
||||||
setSessionDetail(null)
|
setSessionDetail(null)
|
||||||
setMetricDraft({})
|
setMetricDraft({})
|
||||||
setSessionLoadError(null)
|
setSessionLoadError(null)
|
||||||
|
prevEditingIdRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
setSessionLoadError(null)
|
setSessionLoadError(null)
|
||||||
|
if (prevEditingIdRef.current !== editing.id) {
|
||||||
|
setSessionDetail(null)
|
||||||
|
prevEditingIdRef.current = editing.id
|
||||||
|
}
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
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)
|
if (!cancelled) setSessionDetail(d)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -527,25 +679,89 @@ export default function ActivityPage() {
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [editing?.id])
|
}, [editing?.id, editing?.training_category, editing?.training_type_id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionDetail) {
|
if (!sessionDetail) {
|
||||||
setMetricDraft({})
|
setMetricDraft({})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const m = {}
|
const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key))
|
||||||
for (const row of sessionDetail.metrics || []) {
|
const oldKeys = editSchemaKeysPrevRef.current
|
||||||
m[row.key] = row.value
|
|
||||||
}
|
setMetricDraft((prev) => {
|
||||||
for (const s of sessionDetail.schema || []) {
|
const next = { ...prev }
|
||||||
if (!(s.key in m)) {
|
for (const row of sessionDetail.metrics || []) {
|
||||||
m[s.key] = s.data_type === 'boolean' ? false : ''
|
const k = row.key
|
||||||
|
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[k] = row.value
|
||||||
}
|
}
|
||||||
}
|
for (const s of sessionDetail.schema || []) {
|
||||||
setMetricDraft(m)
|
if (!(s.key in next)) {
|
||||||
|
next[s.key] = s.data_type === 'boolean' ? false : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
editSchemaKeysPrevRef.current = newKeys
|
||||||
}, [sessionDetail])
|
}, [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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
@ -565,7 +781,20 @@ export default function ActivityPage() {
|
||||||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||||
payload.source = 'manual'
|
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)
|
setSaved(true)
|
||||||
await load()
|
await load()
|
||||||
await loadUsage() // Reload usage after save
|
await loadUsage() // Reload usage after save
|
||||||
|
|
@ -624,7 +853,26 @@ export default function ActivityPage() {
|
||||||
: timePayloadFromInput(payload.end_time)
|
: timePayloadFromInput(payload.end_time)
|
||||||
await api.updateActivity(editing.id, payload)
|
await api.updateActivity(editing.id, payload)
|
||||||
if (sessionDetail?.schema?.length > 0) {
|
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 })
|
await api.putActivityMetrics(editing.id, { metrics })
|
||||||
}
|
}
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
|
@ -712,9 +960,23 @@ export default function ActivityPage() {
|
||||||
<span>Training eintragen</span>
|
<span>Training eintragen</span>
|
||||||
{activityUsage && <UsageBadge {...activityUsage} />}
|
{activityUsage && <UsageBadge {...activityUsage} />}
|
||||||
</div>
|
</div>
|
||||||
<EntryForm form={form} setForm={setForm}
|
<EntryForm
|
||||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
form={form}
|
||||||
saving={saving} error={error} usage={activityUsage}/>
|
setForm={setForm}
|
||||||
|
onSave={handleSave}
|
||||||
|
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
|
||||||
|
saving={saving}
|
||||||
|
error={error}
|
||||||
|
usage={activityUsage}
|
||||||
|
formExtras={
|
||||||
|
<SessionMetricsFields
|
||||||
|
schema={manualSchema}
|
||||||
|
metrics={[]}
|
||||||
|
values={manualMetricDraft}
|
||||||
|
setValues={setManualMetricDraft}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ const emptyParamForm = () => ({
|
||||||
key: '',
|
key: '',
|
||||||
name_de: '',
|
name_de: '',
|
||||||
name_en: '',
|
name_en: '',
|
||||||
|
description_de: '',
|
||||||
|
description_en: '',
|
||||||
category: 'physical',
|
category: 'physical',
|
||||||
data_type: 'float',
|
data_type: 'float',
|
||||||
unit: '',
|
unit: '',
|
||||||
|
|
@ -130,6 +132,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
key: paramForm.key.trim().toLowerCase(),
|
key: paramForm.key.trim().toLowerCase(),
|
||||||
name_de: paramForm.name_de.trim(),
|
name_de: paramForm.name_de.trim(),
|
||||||
name_en: paramForm.name_en.trim(),
|
name_en: paramForm.name_en.trim(),
|
||||||
|
description_de: paramForm.description_de.trim() || null,
|
||||||
|
description_en: paramForm.description_en.trim() || null,
|
||||||
category: paramForm.category,
|
category: paramForm.category,
|
||||||
data_type: paramForm.data_type,
|
data_type: paramForm.data_type,
|
||||||
unit: paramForm.unit.trim() || null,
|
unit: paramForm.unit.trim() || null,
|
||||||
|
|
@ -153,6 +157,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
await api.adminUpdateTrainingParameter(editParam.id, {
|
await api.adminUpdateTrainingParameter(editParam.id, {
|
||||||
name_de: editParam.name_de.trim(),
|
name_de: editParam.name_de.trim(),
|
||||||
name_en: editParam.name_en.trim(),
|
name_en: editParam.name_en.trim(),
|
||||||
|
description_de: editParam.description_de?.trim() || null,
|
||||||
|
description_en: editParam.description_en?.trim() || null,
|
||||||
category: editParam.category,
|
category: editParam.category,
|
||||||
data_type: editParam.data_type,
|
data_type: editParam.data_type,
|
||||||
unit: editParam.unit?.trim() || null,
|
unit: editParam.unit?.trim() || null,
|
||||||
|
|
@ -273,7 +279,7 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="capture-page">
|
<div className="capture-page activity-attribute-profiles">
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
|
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
|
||||||
← Training (Hub)
|
← Training (Hub)
|
||||||
|
|
@ -302,6 +308,11 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
|
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).
|
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -367,70 +378,151 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 12,
|
padding: 16,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
background: 'var(--surface2)',
|
background: 'var(--surface2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="form-row">
|
<div className="aaf-stack">
|
||||||
<label className="form-label">key</label>
|
<div className="aaf-field">
|
||||||
<input
|
<label className="aaf-label" htmlFor="aaf-new-key">
|
||||||
className="form-input"
|
Technischer Schlüssel (key)
|
||||||
placeholder="z. B. avg_power"
|
</label>
|
||||||
value={paramForm.key}
|
<input
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
id="aaf-new-key"
|
||||||
/>
|
className="aaf-input"
|
||||||
</div>
|
placeholder="z. B. avg_power"
|
||||||
<div className="form-row">
|
value={paramForm.key}
|
||||||
<label className="form-label">name_de / name_en</label>
|
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
||||||
<input
|
/>
|
||||||
className="form-input"
|
</div>
|
||||||
value={paramForm.name_de}
|
<div className="aaf-field">
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
<span className="aaf-label">Bezeichnung</span>
|
||||||
/>
|
<div className="aaf-split">
|
||||||
<input
|
<div>
|
||||||
className="form-input"
|
<label className="aaf-sublabel" htmlFor="aaf-new-name-de">
|
||||||
value={paramForm.name_en}
|
Deutsch
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
</label>
|
||||||
/>
|
<input
|
||||||
</div>
|
id="aaf-new-name-de"
|
||||||
<div className="form-row">
|
className="aaf-input"
|
||||||
<label className="form-label">Gruppe / Datentyp</label>
|
value={paramForm.name_de}
|
||||||
<select
|
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||||
className="form-input"
|
/>
|
||||||
value={paramForm.category}
|
</div>
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
|
<div>
|
||||||
>
|
<label className="aaf-sublabel" htmlFor="aaf-new-name-en">
|
||||||
{PARAM_GROUP.map((c) => (
|
English
|
||||||
<option key={c} value={c}>
|
</label>
|
||||||
{c}
|
<input
|
||||||
</option>
|
id="aaf-new-name-en"
|
||||||
))}
|
className="aaf-input"
|
||||||
</select>
|
value={paramForm.name_en}
|
||||||
<select
|
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||||
className="form-input"
|
/>
|
||||||
value={paramForm.data_type}
|
</div>
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
|
</div>
|
||||||
>
|
</div>
|
||||||
{DATA_TYPES.map((c) => (
|
<div className="aaf-field">
|
||||||
<option key={c} value={c}>
|
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
|
||||||
{c}
|
<div className="aaf-split">
|
||||||
</option>
|
<div>
|
||||||
))}
|
<label className="aaf-sublabel" htmlFor="aaf-new-desc-de">
|
||||||
</select>
|
Deutsch
|
||||||
</div>
|
</label>
|
||||||
<div className="form-row">
|
<textarea
|
||||||
<label className="form-label">Einheit / source_field</label>
|
id="aaf-new-desc-de"
|
||||||
<input
|
className="aaf-input"
|
||||||
className="form-input"
|
rows={3}
|
||||||
value={paramForm.unit}
|
placeholder="Was bedeutet der Wert? Einheit, Skala, Herkunft …"
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
value={paramForm.description_de}
|
||||||
/>
|
onChange={(e) => setParamForm((f) => ({ ...f, description_de: e.target.value }))}
|
||||||
<input
|
/>
|
||||||
className="form-input"
|
</div>
|
||||||
value={paramForm.source_field}
|
<div>
|
||||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
<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>
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||||
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
|
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
|
||||||
|
|
@ -448,63 +540,138 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid var(--accent)',
|
border: '1px solid var(--accent)',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
padding: 12,
|
padding: 16,
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="card-title" style={{ fontSize: 14 }}>
|
<div className="card-title" style={{ fontSize: 14 }}>
|
||||||
Bearbeiten: <code>{editParam.key}</code>
|
Bearbeiten: <code>{editParam.key}</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="aaf-stack">
|
||||||
<label className="form-label">name_de / name_en</label>
|
<div className="aaf-field">
|
||||||
<input
|
<span className="aaf-label">Bezeichnung</span>
|
||||||
className="form-input"
|
<div className="aaf-split">
|
||||||
value={editParam.name_de || ''}
|
<div>
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
<label className="aaf-sublabel" htmlFor="aaf-edit-name-de">
|
||||||
/>
|
Deutsch
|
||||||
<input
|
</label>
|
||||||
className="form-input"
|
<input
|
||||||
value={editParam.name_en || ''}
|
id="aaf-edit-name-de"
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
className="aaf-input"
|
||||||
/>
|
value={editParam.name_de || ''}
|
||||||
</div>
|
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
||||||
<div className="form-row">
|
/>
|
||||||
<label className="form-label">Gruppe / Typ</label>
|
</div>
|
||||||
<select
|
<div>
|
||||||
className="form-input"
|
<label className="aaf-sublabel" htmlFor="aaf-edit-name-en">
|
||||||
value={editParam.category}
|
English
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
|
</label>
|
||||||
>
|
<input
|
||||||
{PARAM_GROUP.map((c) => (
|
id="aaf-edit-name-en"
|
||||||
<option key={c} value={c}>
|
className="aaf-input"
|
||||||
{c}
|
value={editParam.name_en || ''}
|
||||||
</option>
|
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
||||||
))}
|
/>
|
||||||
</select>
|
</div>
|
||||||
<select
|
</div>
|
||||||
className="form-input"
|
</div>
|
||||||
value={editParam.data_type}
|
<div className="aaf-field">
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
|
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
|
||||||
>
|
<div className="aaf-split">
|
||||||
{DATA_TYPES.map((c) => (
|
<div>
|
||||||
<option key={c} value={c}>
|
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-de">
|
||||||
{c}
|
Deutsch
|
||||||
</option>
|
</label>
|
||||||
))}
|
<textarea
|
||||||
</select>
|
id="aaf-edit-desc-de"
|
||||||
</div>
|
className="aaf-input"
|
||||||
<div className="form-row">
|
rows={3}
|
||||||
<label className="form-label">Einheit / source_field</label>
|
value={editParam.description_de || ''}
|
||||||
<input
|
onChange={(e) => setEditParam((p) => ({ ...p, description_de: e.target.value }))}
|
||||||
className="form-input"
|
/>
|
||||||
value={editParam.unit || ''}
|
</div>
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
|
<div>
|
||||||
/>
|
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-en">
|
||||||
<input
|
English
|
||||||
className="form-input"
|
</label>
|
||||||
value={editParam.source_field || ''}
|
<textarea
|
||||||
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
|
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>
|
</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -582,13 +749,15 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
{tab === 'category' && (
|
{tab === 'category' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
||||||
<div className="form-row">
|
<div className="aaf-field-select">
|
||||||
<label className="form-label">Kategorie</label>
|
<label className="form-label" htmlFor="aaf-cat-pick">
|
||||||
|
Kategorie
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="aaf-cat-pick"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={selCategory}
|
value={selCategory}
|
||||||
onChange={(e) => setSelCategory(e.target.value)}
|
onChange={(e) => setSelCategory(e.target.value)}
|
||||||
style={{ maxWidth: 280 }}
|
|
||||||
>
|
>
|
||||||
{categoryKeys.map((k) => (
|
{categoryKeys.map((k) => (
|
||||||
<option key={k} value={k}>
|
<option key={k} value={k}>
|
||||||
|
|
@ -597,10 +766,13 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
<div className="aaf-toolbar">
|
||||||
<div style={{ flex: 1, minWidth: 200 }}>
|
<div className="aaf-toolbar__grow">
|
||||||
<label className="form-label">Parameter</label>
|
<label className="form-label" htmlFor="aaf-cat-param">
|
||||||
|
Parameter
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="aaf-cat-param"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={catAdd.training_parameter_id}
|
value={catAdd.training_parameter_id}
|
||||||
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||||
|
|
@ -613,17 +785,22 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="aaf-toolbar__compact">
|
||||||
<label className="form-label">sort</label>
|
<label className="form-label" htmlFor="aaf-cat-sort">
|
||||||
|
Sortierung
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="aaf-cat-sort"
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 80 }}
|
|
||||||
value={catAdd.sort_order}
|
value={catAdd.sort_order}
|
||||||
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={catAdd.required}
|
checked={catAdd.required}
|
||||||
|
|
@ -631,11 +808,14 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
/>
|
/>
|
||||||
Pflicht
|
Pflicht
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<div className="aaf-toolbar__compact">
|
||||||
<label className="form-label">ui_group</label>
|
<label className="form-label" htmlFor="aaf-cat-uigroup">
|
||||||
|
ui_group
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="aaf-cat-uigroup"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 120 }}
|
placeholder="optional"
|
||||||
value={catAdd.ui_group}
|
value={catAdd.ui_group}
|
||||||
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
@ -655,21 +835,20 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editingCatId === l.id ? (
|
{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' }}>
|
<span style={{ flex: '1 1 200px' }}>
|
||||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
|
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">sort</label>
|
<label className="form-label">Sortierung</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 72 }}
|
|
||||||
value={catDraft.sort_order}
|
value={catDraft.sort_order}
|
||||||
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, paddingBottom: 4 }}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!catDraft.required}
|
checked={!!catDraft.required}
|
||||||
|
|
@ -679,7 +858,6 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 100 }}
|
|
||||||
placeholder="ui_group"
|
placeholder="ui_group"
|
||||||
value={catDraft.ui_group}
|
value={catDraft.ui_group}
|
||||||
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
||||||
|
|
@ -739,13 +917,15 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
{tab === 'type' && (
|
{tab === 'type' && (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
||||||
<div className="form-row">
|
<div className="aaf-field-select">
|
||||||
<label className="form-label">Trainingstyp</label>
|
<label className="form-label" htmlFor="aaf-type-pick">
|
||||||
|
Trainingstyp
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="aaf-type-pick"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={selTypeId}
|
value={selTypeId}
|
||||||
onChange={(e) => setSelTypeId(e.target.value)}
|
onChange={(e) => setSelTypeId(e.target.value)}
|
||||||
style={{ maxWidth: 420 }}
|
|
||||||
>
|
>
|
||||||
{flatTypes.map((t) => (
|
{flatTypes.map((t) => (
|
||||||
<option key={t.id} value={t.id}>
|
<option key={t.id} value={t.id}>
|
||||||
|
|
@ -754,10 +934,13 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
<div className="aaf-toolbar">
|
||||||
<div style={{ flex: 1, minWidth: 200 }}>
|
<div className="aaf-toolbar__grow">
|
||||||
<label className="form-label">Parameter</label>
|
<label className="form-label" htmlFor="aaf-type-param">
|
||||||
|
Parameter
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="aaf-type-param"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={typeAdd.training_parameter_id}
|
value={typeAdd.training_parameter_id}
|
||||||
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||||
|
|
@ -770,21 +953,25 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="aaf-toolbar__compact">
|
||||||
<label className="form-label">sort (leer=Erben)</label>
|
<label className="form-label" htmlFor="aaf-type-sort">
|
||||||
|
Sortierung (leer = erben)
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="aaf-type-sort"
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 80 }}
|
|
||||||
value={typeAdd.sort_order}
|
value={typeAdd.sort_order}
|
||||||
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="aaf-toolbar__compact">
|
||||||
<label className="form-label">Pflicht (leer=Erben)</label>
|
<label className="form-label" htmlFor="aaf-type-req">
|
||||||
|
Pflicht (leer = erben)
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
|
id="aaf-type-req"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 100 }}
|
|
||||||
value={typeAdd.required}
|
value={typeAdd.required}
|
||||||
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
|
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
|
||||||
>
|
>
|
||||||
|
|
@ -793,11 +980,14 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
<option value="false">nein</option>
|
<option value="false">nein</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="aaf-toolbar__compact">
|
||||||
<label className="form-label">ui_group</label>
|
<label className="form-label" htmlFor="aaf-type-uigroup">
|
||||||
|
ui_group
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="aaf-type-uigroup"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 120 }}
|
placeholder="optional"
|
||||||
value={typeAdd.ui_group}
|
value={typeAdd.ui_group}
|
||||||
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
@ -817,23 +1007,21 @@ export default function AdminActivityAttributeProfilesPage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{editingTypeId === l.id ? (
|
{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' }}>
|
<span style={{ flex: '1 1 200px' }}>
|
||||||
<strong>{l.parameter_key}</strong>
|
<strong>{l.parameter_key}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">sort</label>
|
<label className="form-label">Sortierung</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 72 }}
|
|
||||||
value={typeDraft.sort_order}
|
value={typeDraft.sort_order}
|
||||||
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
style={{ width: 100 }}
|
|
||||||
value={typeDraft.required}
|
value={typeDraft.required}
|
||||||
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
|
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,39 @@ export const api = {
|
||||||
adminDeleteTrainingTypeParameter: (id) =>
|
adminDeleteTrainingTypeParameter: (id) =>
|
||||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
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) =>
|
putActivityMetrics: (id, body) =>
|
||||||
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
|
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user