Merge pull request 'Erste Version Platzhalter EAV' (#86) from develop into main
Reviewed-on: #86
This commit is contained in:
commit
a62c952097
|
|
@ -55,6 +55,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
|
||||
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
|
||||
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
|
||||
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
|
||||
| Mitgliedschaft / Features | `technical/MEMBERSHIP_SYSTEM.md`, `architecture/FEATURE_ENFORCEMENT.md` | `backend/auth.py`, Feature-Logging, Router mit Enforcement |
|
||||
|
||||
---
|
||||
|
|
@ -114,6 +115,11 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|
|||
| `TRAINING_TYPE_PROFILES_TECHNICAL.md` | Trainingsprofile technisch |
|
||||
| `UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | Universal CSV: Registry, Executor, Vorlagen, Agent-Checkliste |
|
||||
| `ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md` | Session-Metriken EAV, Attributprofile, Layer-1, Prod-Migration |
|
||||
| `ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md` | Composite-Metriken in EAV (JSONB), Archetypen, CSV-Slots, Layer-1-Expand, Migration/Test-Checkliste |
|
||||
| `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` | **Zielarchitektur** Aktivität (Spine/EAV/Composites/Import/Layer 1–2) + **Phasenplan A–F** Produktionsreife |
|
||||
| `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` | Issue #53: Aktivitäts-Platzhalter Layer 1 ↔ 2a (Audit Schritt 1) |
|
||||
| `ACTIVITY_SCALAR_KANON_TABLE.md` | **Skalar-Kanon** Aktivität (eine Semantik → eine Quelle); Phase A |
|
||||
| *(Code)* `backend/data_layer/activity_data_canon.py` | **Kanon** activity CSV-Modul vs. EAV-primär; Legacy-Lesefallback |
|
||||
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
**Ziel:** Sportspezifische **Attributprofile** (Kategorie + optional Trainingstyp-Override) administrierbar; Messwerte pro Session in **EAV**; **alle Auswertungen** sollen künftig über **Layer 1** (`data_layer`) laufen.
|
||||
|
||||
**Zielarchitektur, Phasenplan (Produktionsreife):** [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md) – Kanon `activity_log`/EAV, Composites, Import, Layer 1/2, Reihenfolge A–F.
|
||||
|
||||
**Composite-Parameter (EAV, JSONB, Archetypen):** detailliertes Umsetzungskonzept für Agenten: [`ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md`](./ACTIVITY_COMPOSITE_METRICS_IMPLEMENTATION_CONCEPT.md).
|
||||
|
||||
**Kanon (Code):** `backend/data_layer/activity_data_canon.py` (Repo-Root) — CSV-Modul `activity` vs. EAV-primär; Migration **057**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Produktions-Migrationen (Pflicht)
|
||||
|
|
@ -41,7 +47,9 @@
|
|||
|
||||
| Modul | Pfad | Aufgabe |
|
||||
|-------|------|---------|
|
||||
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`. |
|
||||
| Session-Metriken & Schema | `backend/data_layer/activity_session_metrics.py` | `resolve_activity_attribute_schema`, `fetch_activity_session_metrics`, `replace_activity_session_metrics`, `get_activity_session_logical_unit`, `enrich_sessions_with_metrics`, `merge_column_backed_and_eav_metrics`. |
|
||||
|
||||
**Spalten vs. EAV (Lesepfad):** `merge_column_backed_and_eav_metrics` / `get_activity_session_logical_unit` / `enrich_sessions_with_metrics` werten Parameter mit `source_field` **primär aus `activity_log`** aus; EAV ist Fallback (z. B. Legacy) oder für Parameter ohne Spalte. **Kein** automatischer Spalte→EAV-Schreib-Sync mehr in `run_activity_post_write_hooks` / Import-Hooks (vermeidet Doppelhaltung).
|
||||
|
||||
**Regeln für Agenten:**
|
||||
|
||||
|
|
@ -49,6 +57,8 @@
|
|||
- Platzhalter / Charts, die Session-Details brauchen: **nur** diese Layer-1-Helfer erweitern oder aufrufen (z. B. `activity_metrics.get_training_sessions_recent_weeks_data` nutzt `enrich_sessions_with_metrics`).
|
||||
- Router: `get_db`, `get_cursor`, Auth; Business-Validierung delegieren an `activity_session_metrics`.
|
||||
|
||||
**KI-Kontext:** In `training_sessions_recent_json` enthält jedes Element von `session_metrics` neben `key`/`value` die Felder `name_de`, `name_en`, `description_de`, `description_en` (aus dem effektiven Schema). Für nicht selbsterklärende Keys soll im Katalog `training_parameters.description_*` gepflegt werden (Admin). Ergänzend liefert der Platzhalter `{{training_parameters_glossary_md}}` die gesamte aktive Parameter-Legende als Markdown-Tabelle (`get_training_parameters_ki_glossary_data` → `get_training_parameters_glossary_md`).
|
||||
|
||||
---
|
||||
|
||||
## 4. API (Ist / geplant)
|
||||
|
|
@ -81,10 +91,23 @@ Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_a
|
|||
|
||||
## 5. Agent-Checkliste (nächste Iterationen)
|
||||
|
||||
**Layer 2a (Platzhalter Aktivität):** Abgleich Registry ↔ Resolver ↔ Layer 1 — [`ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md`](./ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md) (Issue #53). **Schritt 2:** `semantic_contract` / `known_limitations` für dynamische `session_metrics` und Korrektur `trainingstyp_verteilung` in der Registry.
|
||||
|
||||
Siehe **Phasen A–F** in [`ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md`](./ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md). Kurz:
|
||||
|
||||
- [x] **Phase A:** Kanon-Tabelle (eine Quelle pro Semantik) — [`ACTIVITY_SCALAR_KANON_TABLE.md`](./ACTIVITY_SCALAR_KANON_TABLE.md).
|
||||
- [ ] **Phase B:** Lesepfad Layer 1 härten (Consumer-Audit fortlaufend — siehe `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` §4 Phase B).
|
||||
- [ ] **Phase C:** Schreibpfad: Doppelhaltung / Sync stufenweise abschalten.
|
||||
- [ ] **Phase D:** Composite-MVP (ein Archetyp E2E).
|
||||
- [ ] **Phase E:** Archetypen ausbauen + CSV-Typkonvertierung vollständig + Mapping-UX.
|
||||
- [ ] **Phase F:** Härtung Prod (Indizes, Observability, Doku).
|
||||
|
||||
Legacy-Punkte:
|
||||
|
||||
- [x] Admin-UI: `frontend/src/pages/AdminActivityAttributeProfilesPage.jsx`, Route `/admin/activity-attribute-profiles`, Admin-Nav-Gruppe „Trainingstypen“.
|
||||
- [x] `/activity` Frontend: Bearbeiten lädt `GET /api/activity/{id}`, dynamische Felder + `PUT /api/activity/{id}/metrics`.
|
||||
- [ ] Universal CSV: Mapping-Spalten → `training_parameters.key` + Schreiben in EAV (Executor).
|
||||
- [ ] Optional: Backfill `activity_log.*` → `activity_session_metrics` nach `source_field`.
|
||||
- [ ] Universal CSV: Mapping inkl. EAV/Composite-Ziele + Executor (fortlaufend).
|
||||
- [ ] Optional: Backfill / Abschluss `source_field`-Pfad nach Kanon (Phase A/C).
|
||||
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
|
||||
|
||||
---
|
||||
|
|
@ -116,6 +139,7 @@ Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_val
|
|||
- Migration 004/014: `training_types`, `activity_log`-Erweiterungen
|
||||
- Pattern Admin-Katalog: `routers/admin_reference_value_types.py`
|
||||
- Platzhalter Session-JSON: `data_layer/activity_metrics.py` → `get_training_sessions_recent_weeks_data`
|
||||
- KI-Legende: `get_training_parameters_ki_glossary_data`, Platzhalter `{{training_parameters_glossary_md}}`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
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`).
|
||||
- **DB:** `training_category_parameter`, `training_type_parameter`, `activity_session_metrics`; `activity_log.started_at` / `ended_at` (nullable).
|
||||
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` (wenn befüllt).
|
||||
- **API:** Admin `/api/admin/training-parameters`, `/api/admin/training-category-parameters`, `/api/admin/training-type-parameters`; Nutzer `GET /api/activity/{id}`, `PUT /api/activity/{id}/metrics`; Platzhalter-Pfad `training_sessions_recent_json` liefert pro Session `session_metrics` inkl. `name_*` / `description_*`; **`{{training_parameters_glossary_md}}`** = Markdown-Legende aller aktiven Parameter (KI).
|
||||
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
|
||||
|
||||
### Updates (16.04.2026 - Aktivität Phase A abgeschlossen, Phase B gestartet)
|
||||
|
||||
- **Phase A:** Skalar-Kanon schriftlich fixiert — `.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md`; `ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` v1.1; Agent-Guide Checkliste Phase A erledigt.
|
||||
- **Phase B:** `GET /api/activity` (Liste) reichert jede Zeile mit `session_metrics` über `enrich_sessions_with_metrics` an (gleiche Merge-Logik wie Detail); Consumer-Audit-Tabelle in Produktions-Architektur-Dok §4 Phase B.
|
||||
- **Phase B (Export):** `routers/exportdata.py` — JSON-Export `activity` mit `session_metrics`; CSV-Gesamtexport Training-Details mit EAV-Zusammenfassung; ZIP `data/activity.csv` mit Zusatzspalte `session_metrics_json` (Standard-Import unverändert).
|
||||
- **Issue #53 / Layer 2a:** `ACTIVITY_LAYER2A_PLACEHOLDER_AUDIT.md` — alle 20 Aktivitäts-Platzhalter gegen Layer 1 geprüft; Registry-Fix `activity_summary.resolver_function` → `get_activity_summary`.
|
||||
- **Layer 2a Schritt 2:** Registry-Texte `activity_detail`, `training_sessions_recent_json` (dynamische session_metrics, Merge-Kanon); `trainingstyp_verteilung` Metadaten an Phase-0c-Code angeglichen.
|
||||
|
||||
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
|
||||
|
||||
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **Mifflin–St Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance).
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
},
|
||||
},
|
||||
},
|
||||
# Kanon: nur Kern/spine + „heiße“ Metriken → activity_log. Erweiterte Parameter → training_parameters / EAV
|
||||
# (siehe backend/data_layer/activity_data_canon.py).
|
||||
"activity": {
|
||||
"table": "activity_log",
|
||||
"fields": {
|
||||
|
|
@ -63,16 +65,7 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
|||
"max": 220,
|
||||
"label_de": "Herzfrequenz max (bpm)",
|
||||
},
|
||||
"hr_min": {"type": "int", "required": False, "label_de": "Herzfrequenz min (bpm)"},
|
||||
"rpe": {"type": "int", "required": False, "label_de": "RPE (1–10)"},
|
||||
"pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"},
|
||||
"cadence": {"type": "int", "required": False, "label_de": "Kadenz"},
|
||||
"avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"},
|
||||
"elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"},
|
||||
"temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"},
|
||||
"humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"},
|
||||
"avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"},
|
||||
"kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"},
|
||||
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
|
||||
},
|
||||
"derive_date_from_datetime_field": "start_time",
|
||||
|
|
|
|||
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_inter_session_gap_data(): Pausen zwischen Einheiten (Stunden)
|
||||
- get_training_sessions_recent_weeks_data(): Wochen-JSON für KI-Kontext
|
||||
- get_training_parameters_ki_glossary_data(): Parameter-Katalog (Feld, Namen, Beschreibungen) für KI
|
||||
|
||||
All functions return structured data (dict) without formatting.
|
||||
Use placeholder_resolver.py for formatted strings for AI.
|
||||
|
|
@ -1179,3 +1180,32 @@ def get_training_sessions_recent_weeks_data(
|
|||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_training_parameters_ki_glossary_data(profile_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Alle aktiven ``training_parameters`` für KI-Kontext (z. B. neben ``training_sessions_recent_json``).
|
||||
|
||||
Enthält technischen key, name_de/name_en, description_de/description_en, data_type, unit, category.
|
||||
|
||||
Args:
|
||||
profile_id: Reserviert für spätere Einschränkung (z. B. nur im Profil vorkommende Keys);
|
||||
aktuell ungenutzt, Signatur bleibt für Platzhalter-Resolver.
|
||||
"""
|
||||
_ = profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT key, name_de, name_en, description_de, description_en,
|
||||
data_type, unit, category
|
||||
FROM training_parameters
|
||||
WHERE is_active = true
|
||||
ORDER BY category, key
|
||||
"""
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
return {
|
||||
"parameters": rows,
|
||||
"meta": {"count": len(rows), "scope": "global_active_catalog"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""
|
||||
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, Spalten→EAV).
|
||||
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval).
|
||||
|
||||
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ from typing import Any, Dict, List, Mapping, Optional
|
|||
from models import ActivityEntry
|
||||
|
||||
from csv_parser.module_registry import get_module_definition
|
||||
from data_layer.activity_session_metrics import sync_column_backed_session_metrics
|
||||
from data_layer.activity_data_canon import get_activity_module_registry_field_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -51,10 +51,8 @@ _ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "a
|
|||
|
||||
|
||||
def activity_registry_field_keys() -> frozenset[str]:
|
||||
mod = get_module_definition("activity")
|
||||
if not mod:
|
||||
return frozenset()
|
||||
return frozenset((mod.get("fields") or {}).keys())
|
||||
"""Gleiche Menge wie ``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS`` (Registry als Single Source)."""
|
||||
return get_activity_module_registry_field_keys()
|
||||
|
||||
|
||||
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
|
|
@ -248,7 +246,7 @@ def insert_activity_csv_minimal(
|
|||
|
||||
|
||||
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
|
||||
"""Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten."""
|
||||
"""Auto-Eval (falls aktiv). Kein Spalte→EAV-Sync: Lesepfad merge_column_backed_and_eav_metrics."""
|
||||
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -269,7 +267,6 @@ def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
|
|||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_error:
|
||||
logger.error("[AUTO-EVAL] activity %s: %s", eid, eval_error)
|
||||
sync_column_backed_session_metrics(cur, str(profile_id), str(eid))
|
||||
|
||||
|
||||
def run_activity_post_write_hooks_import(
|
||||
|
|
@ -286,7 +283,7 @@ def run_activity_post_write_hooks_import(
|
|||
kcal_active: Any,
|
||||
kcal_resting: Any,
|
||||
) -> None:
|
||||
"""Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict."""
|
||||
"""Auto-Eval nach Import. Kein Spalte→EAV-Sync (siehe run_activity_post_write_hooks)."""
|
||||
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
|
||||
try:
|
||||
activity_dict = {
|
||||
|
|
@ -308,7 +305,6 @@ def run_activity_post_write_hooks_import(
|
|||
_evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, profile_id)
|
||||
except Exception as eval_err:
|
||||
logger.warning("[activity import] Auto-Eval fehlgeschlagen: %s", eval_err)
|
||||
sync_column_backed_session_metrics(cur, str(profile_id), str(eid))
|
||||
|
||||
|
||||
def merge_activity_csv_module_fields(
|
||||
|
|
|
|||
|
|
@ -9,37 +9,13 @@ import logging
|
|||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Mapping, Optional, Sequence
|
||||
|
||||
from csv_parser.module_registry import get_module_definition
|
||||
from data_layer.activity_data_canon import (
|
||||
ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM,
|
||||
ACTIVITY_MODULE_REGISTRY_FIELD_KEYS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# activity_log-Spalten, die per training_parameters.source_field aus CSV (Parameter-Key) befüllt werden dürfen.
|
||||
# Muss mit sync_column_backed_session_metrics übereinstimmen (inkl. Kernmetriken wie hr_avg).
|
||||
ACTIVITY_LOG_PATCHABLE_COLUMNS = frozenset(
|
||||
{
|
||||
"start_time",
|
||||
"end_time",
|
||||
"activity_type",
|
||||
"duration_min",
|
||||
"kcal_active",
|
||||
"kcal_resting",
|
||||
"hr_avg",
|
||||
"hr_max",
|
||||
"hr_min",
|
||||
"distance_km",
|
||||
"rpe",
|
||||
"pace_min_per_km",
|
||||
"cadence",
|
||||
"avg_power",
|
||||
"elevation_gain",
|
||||
"temperature_celsius",
|
||||
"humidity_percent",
|
||||
"avg_hr_percent",
|
||||
"kcal_per_km",
|
||||
"notes",
|
||||
}
|
||||
)
|
||||
|
||||
# Diese Spalten nicht aus CSV-Parameter-Zuordnung überschreiben (kommen aus Typ-Mapping / System).
|
||||
ACTIVITY_LOG_PATCH_FORBIDDEN = frozenset(
|
||||
{
|
||||
|
|
@ -95,6 +71,8 @@ def merge_parameter_schema_rows(
|
|||
"key": r["key"],
|
||||
"name_de": r["name_de"],
|
||||
"name_en": r["name_en"],
|
||||
"description_de": r.get("description_de"),
|
||||
"description_en": r.get("description_en"),
|
||||
"param_category": r["param_category"],
|
||||
"data_type": r["data_type"],
|
||||
"unit": r["unit"],
|
||||
|
|
@ -114,6 +92,8 @@ def merge_parameter_schema_rows(
|
|||
"key": r["key"],
|
||||
"name_de": r["name_de"],
|
||||
"name_en": r["name_en"],
|
||||
"description_de": r.get("description_de"),
|
||||
"description_en": r.get("description_en"),
|
||||
"param_category": r["param_category"],
|
||||
"data_type": r["data_type"],
|
||||
"unit": r["unit"],
|
||||
|
|
@ -157,7 +137,9 @@ def resolve_activity_attribute_schema(
|
|||
tcp.sort_order AS cat_sort,
|
||||
tcp.required AS cat_required,
|
||||
tcp.ui_group AS cat_ui_group,
|
||||
tp.key, tp.name_de, tp.name_en, tp.category AS param_category,
|
||||
tp.key, tp.name_de, tp.name_en,
|
||||
tp.description_de, tp.description_en,
|
||||
tp.category AS param_category,
|
||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
||||
FROM training_category_parameter tcp
|
||||
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
|
||||
|
|
@ -175,7 +157,9 @@ def resolve_activity_attribute_schema(
|
|||
ttp.sort_order AS typ_sort,
|
||||
ttp.required AS typ_required,
|
||||
ttp.ui_group AS typ_ui_group,
|
||||
tp.key, tp.name_de, tp.name_en, tp.category AS param_category,
|
||||
tp.key, tp.name_de, tp.name_en,
|
||||
tp.description_de, tp.description_en,
|
||||
tp.category AS param_category,
|
||||
tp.data_type, tp.unit, tp.validation_rules, tp.source_field
|
||||
FROM training_type_parameter ttp
|
||||
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
|
||||
|
|
@ -188,6 +172,16 @@ def resolve_activity_attribute_schema(
|
|||
return merge_parameter_schema_rows(category_rows, type_rows)
|
||||
|
||||
|
||||
def _metric_human_labels(schema_row: Mapping[str, Any]) -> Dict[str, Any]:
|
||||
"""Bezeichnung + Kurzbeschreibung aus training_parameters (KI / Export)."""
|
||||
return {
|
||||
"name_de": schema_row.get("name_de"),
|
||||
"name_en": schema_row.get("name_en"),
|
||||
"description_de": schema_row.get("description_de"),
|
||||
"description_en": schema_row.get("description_en"),
|
||||
}
|
||||
|
||||
|
||||
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
|
|
@ -276,20 +270,26 @@ def upsert_session_metrics_from_csv_mapped(
|
|||
training_type_id: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen).
|
||||
EAV für Trainingsparameter aus CSV.
|
||||
|
||||
Kernfelder (Datum, Start, Distanz, HF, …) schreibt der Executor nach activity_log;
|
||||
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
|
||||
Es werden nur Parameter geschrieben, die in ``resolve_activity_attribute_schema`` (Kategorie +
|
||||
Trainingstyp) vorkommen. CSV-Spalten-Mappings sind import-spezifisch und definieren **nicht** das
|
||||
UI-/Auswertungs-Schema — fehlende tcp/ttp-Zuordnung bedeutet: kein EAV für diesen Key (Werte ggf.
|
||||
nur in ``activity_log``-Kernfeldern).
|
||||
|
||||
Kernfelder schreibt der Executor nach ``activity_log``; hier keine EAV-Zeilen für Registry-Keys.
|
||||
|
||||
Hat ein Parameter ``source_field`` (Semantik aus ``activity_log``), wird EAV nur dann **nicht**
|
||||
geschrieben, wenn diese Spalte nach dem Import bereits befüllt ist — sonst gäbe es doppelte
|
||||
Speicherung und der Merge würde ohnehin die Spalte bevorzugen. Ist die Spalte leer (z. B. Feld
|
||||
nur noch über EAV / Custom-Mapping, ohne Registry-Patch), schreibt der Import den Wert aus
|
||||
``mapped`` nach EAV — analog zum Lesepfad (Spalte zuerst, sonst EAV).
|
||||
"""
|
||||
cur.execute(
|
||||
"SELECT profile_id FROM activity_log WHERE id = %s",
|
||||
(activity_log_id,),
|
||||
)
|
||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||
row = cur.fetchone()
|
||||
if not row or str(row["profile_id"]) != str(profile_id):
|
||||
return
|
||||
mod = get_module_definition("activity") or {}
|
||||
activity_registry_keys = frozenset((mod.get("fields") or {}).keys())
|
||||
header = dict(row)
|
||||
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
|
||||
for spec in schema:
|
||||
pkey = spec["key"]
|
||||
|
|
@ -298,8 +298,13 @@ def upsert_session_metrics_from_csv_mapped(
|
|||
raw = mapped[pkey]
|
||||
if raw is None or raw == "":
|
||||
continue
|
||||
if pkey in activity_registry_keys:
|
||||
if pkey in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS:
|
||||
continue
|
||||
sf_raw = spec.get("source_field")
|
||||
if sf_raw is not None and str(sf_raw).strip():
|
||||
col = str(sf_raw).strip()
|
||||
if col in header and header[col] is not None:
|
||||
continue
|
||||
tid = spec["training_parameter_id"]
|
||||
dt = spec["data_type"]
|
||||
rules = _validation_rules_dict(spec["validation_rules"])
|
||||
|
|
@ -328,13 +333,113 @@ def upsert_session_metrics_from_csv_mapped(
|
|||
)
|
||||
|
||||
|
||||
def merge_column_backed_and_eav_metrics(
|
||||
header: Mapping[str, Any],
|
||||
schema: Sequence[Dict[str, Any]],
|
||||
eav_metrics: Sequence[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp).
|
||||
|
||||
Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und
|
||||
koerzierbar ist — in dieser Reihenfolge:
|
||||
|
||||
1. ``source_field`` → Spalte
|
||||
2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) → gleichnamige Spalte
|
||||
3. EAV-primäre Keys → Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM``
|
||||
4. sonst EAV
|
||||
|
||||
EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben.
|
||||
"""
|
||||
eav_by_key = {m["key"]: m for m in eav_metrics}
|
||||
merged: List[Dict[str, Any]] = []
|
||||
keys_handled: set[str] = set()
|
||||
|
||||
for s in schema:
|
||||
k = s["key"]
|
||||
tid = s["training_parameter_id"]
|
||||
dt = s["data_type"]
|
||||
unit = s.get("unit")
|
||||
sf = s.get("source_field")
|
||||
|
||||
used_column = False
|
||||
if sf and isinstance(sf, str) and str(sf).strip():
|
||||
col = str(sf).strip()
|
||||
if col in header and header[col] is not None:
|
||||
try:
|
||||
val = _coerce_raw_value_for_parameter(dt, header[col])
|
||||
merged.append(
|
||||
{
|
||||
"training_parameter_id": tid,
|
||||
"key": k,
|
||||
"data_type": dt,
|
||||
"unit": unit,
|
||||
"value": val,
|
||||
**_metric_human_labels(s),
|
||||
}
|
||||
)
|
||||
used_column = True
|
||||
keys_handled.add(k)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if used_column:
|
||||
continue
|
||||
|
||||
if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None:
|
||||
try:
|
||||
val = _coerce_raw_value_for_parameter(dt, header[k])
|
||||
merged.append(
|
||||
{
|
||||
"training_parameter_id": tid,
|
||||
"key": k,
|
||||
"data_type": dt,
|
||||
"unit": unit,
|
||||
"value": val,
|
||||
**_metric_human_labels(s),
|
||||
}
|
||||
)
|
||||
keys_handled.add(k)
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k)
|
||||
if legacy_col and legacy_col in header and header[legacy_col] is not None:
|
||||
try:
|
||||
val = _coerce_raw_value_for_parameter(dt, header[legacy_col])
|
||||
merged.append(
|
||||
{
|
||||
"training_parameter_id": tid,
|
||||
"key": k,
|
||||
"data_type": dt,
|
||||
"unit": unit,
|
||||
"value": val,
|
||||
**_metric_human_labels(s),
|
||||
}
|
||||
)
|
||||
keys_handled.add(k)
|
||||
continue
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
if k in eav_by_key:
|
||||
row = dict(eav_by_key[k])
|
||||
row.update(_metric_human_labels(s))
|
||||
merged.append(row)
|
||||
keys_handled.add(k)
|
||||
|
||||
merged.sort(key=lambda x: x["key"])
|
||||
return merged
|
||||
|
||||
|
||||
def sync_column_backed_session_metrics(cur, profile_id: str, activity_log_id: str) -> None:
|
||||
"""
|
||||
EAV-Zeilen für alle Schema-Parameter mit gesetztem source_field aus der activity_log-Zeile
|
||||
schreiben (Upsert) bzw. bei NULL in der Quellspalte löschen. Reine Layer-1-Logik; keine Router-Abhängigkeit.
|
||||
[Veraltet / nicht mehr in Schreibpfaden aufgerufen]
|
||||
|
||||
Synchron mit Übergangsphase: activity_log bleibt kanonisch für klassische Spalten; EAV spiegelt dieselben
|
||||
Werte für Profil/Platzhalter/Detail-API, ohne replace_activity_session_metrics aufzurufen.
|
||||
Früher: EAV spiegelte activity_log-Spalten für Parameter mit source_field.
|
||||
Kanon: Spaltenwerte werden bei merge_column_backed_and_eav_metrics beim Lesen berücksichtigt; keine
|
||||
doppelte Speicherung. Funktion bleibt für optionale Admin-/Reparatur-Skripte.
|
||||
"""
|
||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||
row = cur.fetchone()
|
||||
|
|
@ -516,47 +621,37 @@ def replace_activity_session_metrics(
|
|||
return fetch_activity_session_metrics(cur, activity_log_id)
|
||||
|
||||
|
||||
def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]:
|
||||
def get_activity_session_logical_unit(
|
||||
cur,
|
||||
profile_id: str,
|
||||
activity_log_id: str,
|
||||
*,
|
||||
use_form_training_context: bool = False,
|
||||
form_training_category: Optional[str] = None,
|
||||
form_training_type_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,))
|
||||
row = cur.fetchone()
|
||||
if not row or str(row["profile_id"]) != str(profile_id):
|
||||
raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden")
|
||||
|
||||
header = dict(row)
|
||||
schema = resolve_activity_attribute_schema(
|
||||
cur, header.get("training_category"), header.get("training_type_id")
|
||||
)
|
||||
if use_form_training_context:
|
||||
cat = form_training_category
|
||||
if isinstance(cat, str):
|
||||
cat = cat.strip() or None
|
||||
tid = form_training_type_id
|
||||
else:
|
||||
cat = header.get("training_category")
|
||||
tid = header.get("training_type_id")
|
||||
if tid is not None:
|
||||
try:
|
||||
tid = int(tid)
|
||||
except (TypeError, ValueError):
|
||||
tid = None
|
||||
schema = resolve_activity_attribute_schema(cur, cat, tid)
|
||||
metrics = fetch_activity_session_metrics(cur, activity_log_id)
|
||||
by_key = {m["key"]: m for m in metrics}
|
||||
merged_metrics: List[Dict[str, Any]] = list(metrics)
|
||||
for s in schema:
|
||||
k = s["key"]
|
||||
if k in by_key:
|
||||
continue
|
||||
sf = s.get("source_field")
|
||||
if not sf or (isinstance(sf, str) and not str(sf).strip()):
|
||||
continue
|
||||
col = str(sf).strip()
|
||||
if col not in header:
|
||||
continue
|
||||
raw = header.get(col)
|
||||
if raw is None:
|
||||
continue
|
||||
dt = s["data_type"]
|
||||
try:
|
||||
val = _coerce_raw_value_for_parameter(dt, raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
merged_metrics.append(
|
||||
{
|
||||
"training_parameter_id": s["training_parameter_id"],
|
||||
"key": k,
|
||||
"data_type": dt,
|
||||
"unit": s.get("unit"),
|
||||
"value": val,
|
||||
}
|
||||
)
|
||||
merged_metrics.sort(key=lambda x: x["key"])
|
||||
merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics)
|
||||
return {
|
||||
"header": header,
|
||||
"schema": schema,
|
||||
|
|
@ -565,17 +660,33 @@ def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str
|
|||
|
||||
|
||||
def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
|
||||
"""Mutates each session dict: adds key 'session_metrics' (list) when sessions non-empty."""
|
||||
"""
|
||||
Mutates each session dict: adds key 'session_metrics' (list).
|
||||
|
||||
Kombiniert EAV mit activity_log-Spalten für Parameter mit source_field (kanonisch: Spalte),
|
||||
analog zu get_activity_session_logical_unit – ohne doppelte EAV-Speicherung beim Import.
|
||||
"""
|
||||
if not sessions:
|
||||
return
|
||||
ids = [str(s["id"]) for s in sessions if s.get("id")]
|
||||
if not ids:
|
||||
return
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
|
||||
cur.execute(
|
||||
f"SELECT * FROM activity_log WHERE id IN ({ph})",
|
||||
ids,
|
||||
)
|
||||
headers_by_id: Dict[str, Dict[str, Any]] = {}
|
||||
for r in cur.fetchall():
|
||||
h = dict(r)
|
||||
headers_by_id[str(h["id"])] = h
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT
|
||||
m.activity_log_id,
|
||||
m.training_parameter_id,
|
||||
tp.key,
|
||||
tp.data_type,
|
||||
tp.unit,
|
||||
|
|
@ -603,8 +714,42 @@ def enrich_sessions_with_metrics(cur, sessions: List[Dict[str, Any]]) -> None:
|
|||
else:
|
||||
val = r["value_bool"]
|
||||
by_act.setdefault(aid, []).append(
|
||||
{"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val}
|
||||
{
|
||||
"training_parameter_id": r["training_parameter_id"],
|
||||
"key": r["key"],
|
||||
"data_type": dt,
|
||||
"unit": r["unit"],
|
||||
"value": val,
|
||||
}
|
||||
)
|
||||
|
||||
schema_cache: Dict[tuple[Any, Any], List[Dict[str, Any]]] = {}
|
||||
|
||||
def _schema(cat: Any, tid: Any) -> List[Dict[str, Any]]:
|
||||
cache_key = (cat, tid)
|
||||
if cache_key not in schema_cache:
|
||||
schema_cache[cache_key] = resolve_activity_attribute_schema(cur, cat, tid)
|
||||
return schema_cache[cache_key]
|
||||
|
||||
for s in sessions:
|
||||
aid = str(s.get("id"))
|
||||
s["session_metrics"] = by_act.get(aid, [])
|
||||
header = headers_by_id.get(aid)
|
||||
if not header:
|
||||
s["session_metrics"] = []
|
||||
continue
|
||||
schema = _schema(header.get("training_category"), header.get("training_type_id"))
|
||||
eav_list = by_act.get(aid, [])
|
||||
merged = merge_column_backed_and_eav_metrics(header, schema, eav_list)
|
||||
s["session_metrics"] = [
|
||||
{
|
||||
"key": m["key"],
|
||||
"data_type": m["data_type"],
|
||||
"unit": m["unit"],
|
||||
"value": m["value"],
|
||||
"name_de": m.get("name_de"),
|
||||
"name_en": m.get("name_en"),
|
||||
"description_de": m.get("description_de"),
|
||||
"description_en": m.get("description_en"),
|
||||
}
|
||||
for m in merged
|
||||
]
|
||||
|
|
|
|||
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
|
||||
|
||||
Registers 17 Aktivitäts-Platzhalter hier; 3 Session-/Erholungs-Keys in activity_session_insights.py (20 gesamt).
|
||||
Registers 17 Aktivitäts-Platzhalter hier; 3 weitere Keys in activity_session_insights.py (**20 gesamt** in PLACEHOLDER_MAP).
|
||||
|
||||
Evidence-based metadata with clear tagging of source.
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ def register_activity_group_1():
|
|||
category="Aktivität",
|
||||
description="Zusammenfassung der letzten 14 Tage Aktivität",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_format_activity_summary",
|
||||
resolver_function="get_activity_summary",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
|
|
@ -127,17 +127,23 @@ def register_activity_group_1():
|
|||
activity_detail_metadata = PlaceholderMetadata(
|
||||
key="activity_detail",
|
||||
category="Aktivität",
|
||||
description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)",
|
||||
description=(
|
||||
"Letzte 14 Tage: pro Session Kopfzeile (activity_log) plus gemergte Profil-Metriken "
|
||||
"(dynamische Keys je training_category / training_type_id)"
|
||||
),
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_activity_detail",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_activity_detail_data",
|
||||
source_tables=["activity_log", "activity_session_metrics", "training_parameters"],
|
||||
semantic_contract=(
|
||||
"Liefert bis zu 50 Einheiten (neueste zuerst) der letzten 14 Tage über "
|
||||
"get_activity_detail_data: activity_log-Spalten plus "
|
||||
"enrich_sessions_with_metrics (activity_session_metrics / Profil-EAV). "
|
||||
"Formatter hängt nicht-leere EAV-Werte als „| EAV: key=value; …“ an."
|
||||
"Layer 1: get_activity_detail_data lädt Sessions, enrich_sessions_with_metrics fügt "
|
||||
"session_metrics hinzu — effektive Liste aus merge_column_backed_and_eav_metrics: nur "
|
||||
"Parameter aus dem Attributschema (tcp/ttp), sortiert nach key. "
|
||||
"Leseregel Kanon: activity_log-Spalte (source_field, Registry-Feld, Legacy-Spalte für "
|
||||
"EAV-primäre Keys) schlägt EAV, wenn beide Werte liefern. "
|
||||
"Layer 2a: Zeilen mit „| EAV: key=value; …“ nur für nicht-leere session_metrics; "
|
||||
"die Menge der Keys ist admin-/profilabhängig, kein festes Prompt-Schema."
|
||||
),
|
||||
business_meaning=(
|
||||
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
|
||||
|
|
@ -167,8 +173,10 @@ def register_activity_group_1():
|
|||
),
|
||||
known_limitations=(
|
||||
"Keine Profil-Qualitätsfilterung in dieser Liste. Max. 20 Zeilen im Prompt-Output "
|
||||
"(Hard-Limit Resolver). Doppelte Spalten (z.B. duration_min in Kopf und EAV) können "
|
||||
"in EAV wiederholt erscheinen — KI kann dominante Spalte nutzen."
|
||||
"(Hard-Limit Resolver). session_metrics kann leer sein (kein Typ, kein Profil, keine EAV-Zeilen). "
|
||||
"Keys und Anzahl Metriken variieren je Instanz/Admin — nicht von festen Platzhaltern in anderen "
|
||||
"Prompts ausgehen. Nur im effektiven Merge erscheinende Parameter; keine verwaisten EAV-Keys "
|
||||
"außerhalb des Schemas."
|
||||
),
|
||||
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
|
||||
layer_2a_decision="get_activity_detail (Formatierung)",
|
||||
|
|
@ -211,56 +219,47 @@ def register_activity_group_1():
|
|||
trainingstyp_verteilung_metadata = PlaceholderMetadata(
|
||||
key="trainingstyp_verteilung",
|
||||
category="Aktivität",
|
||||
description="Trainingstypen-Verteilung der letzten 14 Tage als JSON",
|
||||
description="Verteilung nach training_category (14 Tage): Top 3 als kompakte Prozent-Textzeile",
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_format_trainingstyp_verteilung",
|
||||
data_layer_module=None,
|
||||
data_layer_function=None,
|
||||
source_tables=["activity_log", "training_types"],
|
||||
resolver_function="get_trainingstyp_verteilung",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_type_distribution_data",
|
||||
source_tables=["activity_log"],
|
||||
semantic_contract=(
|
||||
"Liefert eine JSON-Struktur mit der Verteilung der Trainingstypen über 14 Tage. "
|
||||
"Für jeden Trainingstyp: Anzahl Einheiten, Gesamtdauer (Minuten), "
|
||||
"Prozentanteil an Gesamtdauer. Sortiert nach Dauer absteigend."
|
||||
"Layer 1: get_training_type_distribution_data — Anteil je training_category am "
|
||||
"Gesamt-Session-Count im Fenster (auch unkategorisierte zählen im Nenner). "
|
||||
"Layer 2a: Top 3 Kategorien als „Name: p%“ kommagetrennt; bei fehlenden Daten Kurz-Hinweis."
|
||||
),
|
||||
business_meaning=(
|
||||
"Analyse-Placeholder für Trainingsvielfalt und -schwerpunkte. "
|
||||
"Erlaubt KI-Prompts, Imbalancen zu erkennen (z.B. nur Kraft, keine Ausdauer) "
|
||||
"oder Zielkonformität zu prüfen (z.B. 'zu wenig Mobilität')."
|
||||
),
|
||||
unit="json",
|
||||
unit="text",
|
||||
time_window="14d",
|
||||
output_type=OutputType.JSON,
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="JSON Object mit Trainingstyp als Key, Value: {count, duration_min, percentage}",
|
||||
example_output=(
|
||||
'{"Krafttraining": {"count": 5, "duration_min": 180, "percentage": 57}, '
|
||||
'"Ausdauer": {"count": 4, "duration_min": 90, "percentage": 29}, '
|
||||
'"Mobilität": {"count": 3, "duration_min": 45, "percentage": 14}}'
|
||||
),
|
||||
format_hint="Eine Zeile: bis zu drei „Kategorie: Prozent%“, durch Komma getrennt",
|
||||
example_output="cardio: 45%, strength: 30%, mobility: 15%",
|
||||
minimum_data_requirements=None,
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Keine Confidence-Berechnung. Aggregation basiert auf verfügbaren Daten.",
|
||||
confidence_logic="Wie get_training_type_distribution_data (calculate_confidence über categorized_count)",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="{}"
|
||||
legacy_display="Keine kategorisierten Trainings"
|
||||
),
|
||||
known_limitations=(
|
||||
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
|
||||
"Aggregation direkt im Resolver. "
|
||||
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten werden aggregiert. "
|
||||
"JOIN mit training_types für Typ-Namen. "
|
||||
"EDGE CASE: Einheiten ohne training_type_id werden ignoriert (LEFT JOIN)."
|
||||
"Nur Sessions mit gesetztem training_category fließen in die Verteilungsliste; "
|
||||
"Prozente beziehen sich auf alle Sessions im Fenster (Nenner = total_sessions). "
|
||||
"Keine Qualitätsfilterung der Einheiten. Kein drill-down nach training_type_id in diesem Platzhalter."
|
||||
),
|
||||
layer_1_decision="NONE - Old resolver pattern (direct SQL aggregation in resolver)",
|
||||
layer_2a_decision="Placeholder Resolver (aggregation + JSON formatting)",
|
||||
layer_1_decision="activity_metrics.get_training_type_distribution_data",
|
||||
layer_2a_decision="get_trainingstyp_verteilung (Top 3 als Text)",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment=(
|
||||
"PARTIALLY ALIGNED: JSON output structure suitable for chart endpoints, "
|
||||
"but no data layer separation. Should be refactored."
|
||||
),
|
||||
issue_53_alignment="PARTIALLY ALIGNED - output format good, layer separation missing"
|
||||
architecture_alignment="Phase 0c — Layer 1 + Formatierung",
|
||||
issue_53_alignment="Layer 1"
|
||||
)
|
||||
|
||||
trainingstyp_verteilung_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)
|
||||
|
|
|
|||
|
|
@ -130,8 +130,8 @@ def register_activity_session_insights():
|
|||
key="training_sessions_recent_json",
|
||||
category="Aktivität",
|
||||
description=(
|
||||
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, "
|
||||
"session_id, session_metrics[] aus EAV)"
|
||||
"JSON: ISO-Wochen mit Sessions (activity_log-Kopf) plus session_metrics[] — gemergte Profil-Metriken "
|
||||
"(dynamische Keys)"
|
||||
),
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="_safe_json",
|
||||
|
|
@ -139,9 +139,16 @@ def register_activity_session_insights():
|
|||
data_layer_function="get_training_sessions_recent_weeks_data",
|
||||
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
|
||||
semantic_contract=(
|
||||
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung; "
|
||||
"session_metrics[] = Layer-1-EAV-Werte (key, data_type, unit, value) wenn konfiguriert/gespeichert. "
|
||||
"Default 4 ISO-Wochen zurück."
|
||||
"Root: weeks[] mit week_iso; sessions[] pro Einheit u. a. id, date, activity_type, "
|
||||
"duration_min, kcal_active, hr_avg, hr_max, rpe, training_category, training_type_name, "
|
||||
"session_metrics[]. "
|
||||
"session_metrics: effektive Liste nach merge_column_backed_and_eav_metrics — Einträge mit "
|
||||
"training_parameter_id, key, data_type, unit, value, name_de/name_en, description_de/description_en; "
|
||||
"nur Parameter aus Attributschema "
|
||||
"(training_category_parameter + training_type_parameter Overrides), keys sortiert. "
|
||||
"Kanon Lesen: activity_log-Spalte vor EAV bei Konflikt. "
|
||||
"meta: weeks_requested, days_loaded, session_count, confidence. "
|
||||
"Default ca. 4 ISO-Wochen (28 Tage Rohdatenfenster)."
|
||||
),
|
||||
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
|
||||
unit="JSON string",
|
||||
|
|
@ -160,8 +167,12 @@ def register_activity_session_insights():
|
|||
legacy_display="{}",
|
||||
),
|
||||
known_limitations=(
|
||||
"Token-Länge bei vielen Sessions beachten. training_type_name nur bei gesetztem training_type_id. "
|
||||
"session_metrics nur befüllt, wenn Admin-Profile zugeordnet und Werte in EAV gespeichert sind."
|
||||
"Token-Länge bei vielen Sessions. training_type_name nur bei gesetztem training_type_id. "
|
||||
"session_metrics oft [] (kein Typ, kein Profil, keine gespeicherten Werte). "
|
||||
"Anzahl und Namen der Metrik-Keys sind instanz-/adminabhängig — JSON nicht als festes Schema "
|
||||
"für Downstream-Parsing harter Logik verwenden. "
|
||||
"Für KI-Semantik zusätzlich {{training_parameters_glossary_md}} (gesamter aktiver Katalog) in den Prompt legen. "
|
||||
"Composite-Parameter (JSON in EAV) noch nicht im MVP expandiert; ggf. Roh-value_text in späterer Phase."
|
||||
),
|
||||
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
|
||||
layer_2a_decision="_safe_json('training_sessions_recent_json')",
|
||||
|
|
@ -183,5 +194,61 @@ def register_activity_session_insights():
|
|||
_ev(pj, "known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(pj)
|
||||
|
||||
md_gloss = PlaceholderMetadata(
|
||||
key="training_parameters_glossary_md",
|
||||
category="Aktivität",
|
||||
description=(
|
||||
"Markdown-Tabelle: alle aktiven training_parameters (key, DE/EN, Beschreibungen, Typ, Einheit, Kategorie). "
|
||||
"Ergänzung zu training_sessions_recent_json für KI (Bedeutung dynamischer Metrik-Keys)."
|
||||
),
|
||||
resolver_module="backend/placeholder_resolver.py",
|
||||
resolver_function="get_training_parameters_glossary_md",
|
||||
data_layer_module="backend/data_layer/activity_metrics.py",
|
||||
data_layer_function="get_training_parameters_ki_glossary_data",
|
||||
source_tables=["training_parameters"],
|
||||
semantic_contract=(
|
||||
"SELECT auf training_parameters WHERE is_active; sortiert category, key. "
|
||||
"profile_id-Parameter im Resolver reserviert, aktuell globaler Katalog."
|
||||
),
|
||||
business_meaning="KI: Legende zu session_metrics-Keys und Custom-Parametern",
|
||||
unit="Markdown",
|
||||
time_window="n/a (Katalog-Snapshot)",
|
||||
output_type=OutputType.TEXT_SUMMARY,
|
||||
placeholder_type=PlaceholderType.INTERPRETED,
|
||||
format_hint="GitHub-Flavored Markdown-Tabelle",
|
||||
example_output="| Feld (key) | DE | EN | Beschreibung DE | … |",
|
||||
minimum_data_requirements="Optional leer → Kurztext statt Tabelle",
|
||||
quality_filter_policy=None,
|
||||
confidence_logic="Immer verfügbar wenn DB erreichbar",
|
||||
missing_value_policy=MissingValuePolicy(
|
||||
available=False,
|
||||
value_raw=None,
|
||||
missing_reason="no_data",
|
||||
legacy_display="Keine aktiven Trainingsparameter im Katalog.",
|
||||
),
|
||||
known_limitations=(
|
||||
"Keine profil-spezifische Einschränkung auf tatsächlich genutzte Keys (V2). "
|
||||
"Tabellen können bei großem Katalog lang werden."
|
||||
),
|
||||
layer_1_decision="activity_metrics.get_training_parameters_ki_glossary_data",
|
||||
layer_2a_decision="get_training_parameters_glossary_md",
|
||||
layer_2b_reuse_possible=True,
|
||||
architecture_alignment="Phase 0c",
|
||||
issue_53_alignment="Layer 2a",
|
||||
evidence={},
|
||||
)
|
||||
for f in (
|
||||
"key", "category", "description", "resolver_module", "resolver_function",
|
||||
"data_layer_module", "data_layer_function", "source_tables", "semantic_contract",
|
||||
"unit", "time_window", "output_type", "placeholder_type", "format_hint",
|
||||
"example_output", "minimum_data_requirements", "confidence_logic",
|
||||
"missing_value_policy", "layer_1_decision", "layer_2a_decision",
|
||||
"layer_2b_reuse_possible", "architecture_alignment", "issue_53_alignment",
|
||||
):
|
||||
_ev(md_gloss, f)
|
||||
_ev(md_gloss, "business_meaning", EvidenceType.DRAFT_DERIVED)
|
||||
_ev(md_gloss, "known_limitations", EvidenceType.MIXED)
|
||||
register_placeholder(md_gloss)
|
||||
|
||||
|
||||
register_activity_session_insights()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ from data_layer.activity_metrics import (
|
|||
get_training_frequency_by_type_data,
|
||||
get_training_inter_session_gap_data,
|
||||
get_training_sessions_recent_weeks_data,
|
||||
get_training_parameters_ki_glossary_data,
|
||||
)
|
||||
from data_layer.recovery_metrics import (
|
||||
get_sleep_duration_data,
|
||||
|
|
@ -426,7 +427,8 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
|
|||
k, v = m.get("key"), m.get("value")
|
||||
if k is None or v is None:
|
||||
continue
|
||||
eav_parts.append(f"{k}={v}")
|
||||
label = m.get("name_de") or m.get("name_en") or k
|
||||
eav_parts.append(f"{label} ({k})={v}")
|
||||
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
|
||||
lines.append(
|
||||
f"{activity['date']}: {activity['activity_type']} "
|
||||
|
|
@ -456,6 +458,45 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:
|
|||
return ", ".join(parts)
|
||||
|
||||
|
||||
def get_training_parameters_glossary_md(profile_id: str) -> str:
|
||||
"""
|
||||
Markdown-Tabelle: alle aktiven training_parameters (key, Namen, Beschreibungen, Typ, Einheit).
|
||||
Für KI neben session_metrics / training_sessions_recent_json.
|
||||
"""
|
||||
data = get_training_parameters_ki_glossary_data(profile_id)
|
||||
params = data.get("parameters") or []
|
||||
if not params:
|
||||
return "Keine aktiven Trainingsparameter im Katalog."
|
||||
|
||||
def cell(x: object) -> str:
|
||||
if x is None:
|
||||
return "—"
|
||||
return str(x).replace("|", "·").replace("\n", " ").strip()[:400]
|
||||
|
||||
lines = [
|
||||
"| Feld (key) | DE | EN | Beschreibung DE | Beschreibung EN | Typ | Einheit | Kategorie |",
|
||||
"|---|---|---|---|---|---|---|---|",
|
||||
]
|
||||
for p in params:
|
||||
lines.append(
|
||||
"| "
|
||||
+ " | ".join(
|
||||
[
|
||||
cell(p.get("key")),
|
||||
cell(p.get("name_de")),
|
||||
cell(p.get("name_en")),
|
||||
cell(p.get("description_de")),
|
||||
cell(p.get("description_en")),
|
||||
cell(p.get("data_type")),
|
||||
cell(p.get("unit")),
|
||||
cell(p.get("category")),
|
||||
]
|
||||
)
|
||||
+ " |"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_training_frequency_by_type_md(profile_id: str, days: int = 28) -> str:
|
||||
"""
|
||||
Markdown-Tabelle: pro Trainingsart (Roh-Label) Ø Sessions/Woche, Dauer, kcal, HF, RPE, kcal/min.
|
||||
|
|
@ -1524,7 +1565,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
|||
'{{intake_volatility}}': lambda pid: _safe_str('intake_volatility', pid),
|
||||
'{{nutrition_score}}': lambda pid: _safe_int('nutrition_score', pid),
|
||||
|
||||
# Training / Aktivität (17 Registry-Keys — gebündelt; activity_score hier, nicht unter Meta Scores)
|
||||
# Training / Aktivität (20 Keys: 17 activity_metrics + 3 activity_session_insights; activity_score hier, nicht unter Meta Scores)
|
||||
'{{activity_summary}}': get_activity_summary,
|
||||
'{{activity_detail}}': get_activity_detail,
|
||||
'{{trainingstyp_verteilung}}': get_trainingstyp_verteilung,
|
||||
|
|
@ -1545,6 +1586,7 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = {
|
|||
'{{training_frequency_by_type_md}}': get_training_frequency_by_type_md,
|
||||
'{{training_inter_session_gap_md}}': get_training_inter_session_gap_md,
|
||||
'{{training_sessions_recent_json}}': lambda pid: _safe_json('training_sessions_recent_json', pid),
|
||||
'{{training_parameters_glossary_md}}': get_training_parameters_glossary_md,
|
||||
|
||||
# Schlaf & Erholung (10 Registry-Keys; recovery_score hier, nicht unter Meta Scores)
|
||||
'{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7),
|
||||
|
|
@ -1749,6 +1791,7 @@ def get_available_placeholders(categories: Optional[List[str]] = None) -> Dict[s
|
|||
'{{proxy_internal_load_7d}}', '{{monotony_score}}', '{{strain_score}}',
|
||||
'{{rest_day_compliance}}', '{{vo2max_trend_28d}}', '{{activity_score}}',
|
||||
'{{training_frequency_by_type_md}}', '{{training_inter_session_gap_md}}', '{{training_sessions_recent_json}}',
|
||||
'{{training_parameters_glossary_md}}',
|
||||
],
|
||||
'schlaf': [
|
||||
'{{sleep_avg_duration}}', '{{sleep_avg_quality}}', '{{rest_days_count}}',
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ from data_layer.activity_persistence_orchestrator import (
|
|||
new_activity_id,
|
||||
)
|
||||
from data_layer.activity_time_normalize import normalize_activity_start
|
||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
||||
|
||||
router = APIRouter(prefix="/api/activity", tags=["activity"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -71,6 +72,12 @@ def _activity_rows_after_list_query(cur):
|
|||
return rows
|
||||
|
||||
|
||||
def _return_activity_list_rows(cur, rows: list) -> list:
|
||||
"""Layer-1: gemergte session_metrics wie Detail-Pfad (Batch)."""
|
||||
enrich_sessions_with_metrics(cur, rows)
|
||||
return rows
|
||||
|
||||
|
||||
# Evaluation import with error handling (Phase 1.2)
|
||||
try:
|
||||
from evaluation_helper import evaluate_and_save_activity
|
||||
|
|
@ -140,7 +147,7 @@ def list_activity(
|
|||
""",
|
||||
(pid, d0, d1, limit),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
|
|
@ -152,7 +159,9 @@ def list_activity(
|
|||
""",
|
||||
(pid, d0, d1, limit),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
return _return_activity_list_rows(
|
||||
cur, [r2d(r) for r in cur.fetchall()]
|
||||
)
|
||||
|
||||
if days is not None:
|
||||
if collapse_duplicate_sessions:
|
||||
|
|
@ -173,7 +182,7 @@ def list_activity(
|
|||
""",
|
||||
(pid, days, limit, offset),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
|
|
@ -203,7 +212,7 @@ def list_activity(
|
|||
""",
|
||||
(pid, limit, offset),
|
||||
)
|
||||
return _activity_rows_after_list_query(cur)
|
||||
return _return_activity_list_rows(cur, _activity_rows_after_list_query(cur))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
|
|
@ -214,7 +223,7 @@ def list_activity(
|
|||
""",
|
||||
(pid, limit, offset),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
return _return_activity_list_rows(cur, [r2d(r) for r in cur.fetchall()])
|
||||
|
||||
|
||||
@router.post("")
|
||||
|
|
@ -362,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)):
|
|||
return get_mappable_activity_field_catalog(cur, pid)
|
||||
|
||||
|
||||
@router.get("/attribute-schema")
|
||||
def get_activity_attribute_schema(
|
||||
training_category: Optional[str] = Query(None),
|
||||
training_type_id: Optional[int] = Query(None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session —
|
||||
gleiche Logik wie resolve_activity_attribute_schema.
|
||||
"""
|
||||
from data_layer.activity_session_metrics import resolve_activity_attribute_schema
|
||||
|
||||
cat = (training_category or "").strip() or None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
schema = resolve_activity_attribute_schema(cur, cat, training_type_id)
|
||||
return {"schema": schema}
|
||||
|
||||
|
||||
@router.put("/{eid}")
|
||||
def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Update existing activity entry."""
|
||||
|
|
@ -413,6 +441,12 @@ def replace_activity_metrics(
|
|||
@router.get("/{eid}")
|
||||
def get_activity_session(
|
||||
eid: str,
|
||||
use_form_schema: bool = Query(
|
||||
False,
|
||||
description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile",
|
||||
),
|
||||
training_category: Optional[str] = Query(None),
|
||||
training_type_id: Optional[int] = Query(None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
|
||||
|
|
@ -426,7 +460,14 @@ def get_activity_session(
|
|||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
unit = get_activity_session_logical_unit(cur, pid, eid)
|
||||
unit = get_activity_session_logical_unit(
|
||||
cur,
|
||||
pid,
|
||||
eid,
|
||||
use_form_training_context=use_form_schema,
|
||||
form_training_category=training_category,
|
||||
form_training_type_id=training_type_id,
|
||||
)
|
||||
except ActivitySessionMetricsError as err:
|
||||
raise HTTPException(err.status_code, err.detail) from err
|
||||
unit["header"] = serialize_dates(unit["header"])
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ from auth import require_auth, check_feature_access, increment_feature_usage
|
|||
from routers.profiles import get_pid
|
||||
from feature_logger import log_feature_usage
|
||||
from caliper_composition import enrich_caliper_row_for_response, load_weight_rows
|
||||
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
|
||||
from data_layer.utils import serialize_dates
|
||||
|
||||
router = APIRouter(prefix="/api/export", tags=["export"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -90,10 +92,23 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D
|
|||
for r in cur.fetchall():
|
||||
writer.writerow(["Ernährung", r['date'], f"{float(r['kcal'])}kcal", f"Protein:{float(r['protein_g'])}g"])
|
||||
|
||||
# Activity
|
||||
cur.execute("SELECT date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
for r in cur.fetchall():
|
||||
writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"])
|
||||
# Activity (Layer-1: gemergte session_metrics in Details)
|
||||
cur.execute(
|
||||
"SELECT id, date, activity_type, duration_min, kcal_active FROM activity_log WHERE profile_id=%s ORDER BY date",
|
||||
(pid,),
|
||||
)
|
||||
act_rows = [r2d(r) for r in cur.fetchall()]
|
||||
enrich_sessions_with_metrics(cur, act_rows)
|
||||
for r in act_rows:
|
||||
base = f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"
|
||||
eav_parts = []
|
||||
for m in r.get("session_metrics") or []:
|
||||
k, v = m.get("key"), m.get("value")
|
||||
if k is None or v is None:
|
||||
continue
|
||||
eav_parts.append(f"{k}={v}")
|
||||
details = base + (" | " + "; ".join(eav_parts) if eav_parts else "")
|
||||
writer.writerow(["Training", r["date"], r["activity_type"], details])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
|
|
@ -148,7 +163,9 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict=
|
|||
data['nutrition'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
cur.execute("SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
data['activity'] = [r2d(r) for r in cur.fetchall()]
|
||||
data["activity"] = [r2d(r) for r in cur.fetchall()]
|
||||
enrich_sessions_with_metrics(cur, data["activity"])
|
||||
data["activity"] = serialize_dates(data["activity"])
|
||||
|
||||
cur.execute("SELECT * FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||
data['insights'] = [r2d(r) for r in cur.fetchall()]
|
||||
|
|
@ -243,6 +260,9 @@ Dieser Export kann in Mitai Jinkendo unter
|
|||
Einstellungen → Import → "Mitai Backup importieren"
|
||||
wieder eingespielt werden.
|
||||
|
||||
activity.csv (optional): Spalte session_metrics_json (JSON-Array, Layer-1-merge)
|
||||
wird beim Standard-Import ignoriert; für Vollständigkeit/externe Tools.
|
||||
|
||||
Format-Version 2 (ab v9b):
|
||||
Alle CSV-Dateien sind UTF-8 mit BOM kodiert.
|
||||
Trennzeichen: Semikolon (;)
|
||||
|
|
@ -318,13 +338,41 @@ Datumsformat: YYYY-MM-DD
|
|||
r['fiber'] = None; r['note'] = ''
|
||||
write_csv(zf, "nutrition.csv", rows, ['id','date','meal_name','kcal','protein','fat','carbs','fiber','note','source','created'])
|
||||
|
||||
cur.execute("SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date", (pid,))
|
||||
cur.execute(
|
||||
"SELECT id, date, activity_type, duration_min, kcal_active, hr_avg, hr_max, distance_km, notes, source, created FROM activity_log WHERE profile_id=%s ORDER BY date",
|
||||
(pid,),
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
enrich_sessions_with_metrics(cur, rows)
|
||||
for r in rows:
|
||||
r['name'] = r['activity_type']; r['type'] = r.pop('activity_type', None)
|
||||
r['kcal'] = r.pop('kcal_active', None); r['heart_rate_avg'] = r.pop('hr_avg', None)
|
||||
r['heart_rate_max'] = r.pop('hr_max', None); r['note'] = r.pop('notes', None)
|
||||
write_csv(zf, "activity.csv", rows, ['id','date','name','type','duration_min','kcal','heart_rate_avg','heart_rate_max','distance_km','note','source','created'])
|
||||
sm = r.pop("session_metrics", None) or []
|
||||
r["session_metrics_json"] = json.dumps(sm, ensure_ascii=False, default=str)
|
||||
r["name"] = r["activity_type"]
|
||||
r["type"] = r.pop("activity_type", None)
|
||||
r["kcal"] = r.pop("kcal_active", None)
|
||||
r["heart_rate_avg"] = r.pop("hr_avg", None)
|
||||
r["heart_rate_max"] = r.pop("hr_max", None)
|
||||
r["note"] = r.pop("notes", None)
|
||||
write_csv(
|
||||
zf,
|
||||
"activity.csv",
|
||||
rows,
|
||||
[
|
||||
"id",
|
||||
"date",
|
||||
"name",
|
||||
"type",
|
||||
"duration_min",
|
||||
"kcal",
|
||||
"heart_rate_avg",
|
||||
"heart_rate_max",
|
||||
"distance_km",
|
||||
"note",
|
||||
"source",
|
||||
"created",
|
||||
"session_metrics_json",
|
||||
],
|
||||
)
|
||||
|
||||
# 8. insights/ai_insights.json
|
||||
cur.execute("SELECT id, scope, content, created FROM ai_insights WHERE profile_id=%s ORDER BY created DESC", (pid,))
|
||||
|
|
|
|||
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)."""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from data_layer.activity_session_metrics import (
|
||||
ActivitySessionMetricsError,
|
||||
enrich_sessions_with_metrics,
|
||||
merge_column_backed_and_eav_metrics,
|
||||
merge_parameter_schema_rows,
|
||||
resolve_activity_attribute_schema,
|
||||
upsert_session_metrics_from_csv_mapped,
|
||||
_row_value_tuple,
|
||||
_validate_single_value,
|
||||
)
|
||||
|
|
@ -94,6 +97,52 @@ def test_merge_type_overrides_required_and_sort():
|
|||
assert merged[0]["required"] is True
|
||||
|
||||
|
||||
def test_merge_parameter_schema_includes_descriptions():
|
||||
cat = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"cat_sort": 0,
|
||||
"cat_required": False,
|
||||
"cat_ui_group": None,
|
||||
"key": "custom_w",
|
||||
"name_de": "Leistung",
|
||||
"name_en": "Watts",
|
||||
"description_de": "Mittlere 5-Min-Leistung",
|
||||
"description_en": "5 min average power",
|
||||
"param_category": "performance",
|
||||
"data_type": "integer",
|
||||
"unit": "W",
|
||||
"validation_rules": {},
|
||||
"source_field": None,
|
||||
}
|
||||
]
|
||||
merged = merge_parameter_schema_rows(cat, [])
|
||||
assert merged[0]["description_de"] == "Mittlere 5-Min-Leistung"
|
||||
assert merged[0]["description_en"] == "5 min average power"
|
||||
|
||||
|
||||
def test_merge_column_backed_includes_human_labels_from_schema():
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"key": "watts",
|
||||
"data_type": "integer",
|
||||
"unit": "W",
|
||||
"validation_rules": {},
|
||||
"source_field": "avg_power",
|
||||
"name_de": "Leistung",
|
||||
"name_en": "Power",
|
||||
"description_de": "Gerätewert",
|
||||
"description_en": "Device reading",
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"avg_power": 200}, schema, [])
|
||||
assert len(out) == 1
|
||||
assert out[0]["value"] == 200
|
||||
assert out[0]["name_de"] == "Leistung"
|
||||
assert out[0]["description_en"] == "Device reading"
|
||||
|
||||
|
||||
def test_merge_type_adds_parameter_not_in_category():
|
||||
typ = [_ttp_row(7, "cadence", typ_sort=1, typ_required=True, data_type="integer")]
|
||||
merged = merge_parameter_schema_rows([], typ)
|
||||
|
|
@ -171,22 +220,39 @@ def test_resolve_loads_category_from_training_type_id():
|
|||
assert cur.executes[0][1] == (42,)
|
||||
|
||||
|
||||
def test_enrich_sessions_batch():
|
||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
|
||||
def test_enrich_sessions_batch(mock_resolve):
|
||||
aid = str(uuid.uuid4())
|
||||
bid = str(uuid.uuid4())
|
||||
|
||||
class _Cur:
|
||||
def __init__(self):
|
||||
self.params = None
|
||||
self._fetch_n = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
self.sql = sql
|
||||
self.params = params
|
||||
|
||||
def fetchall(self):
|
||||
self._fetch_n += 1
|
||||
if self._fetch_n == 1:
|
||||
return [
|
||||
{
|
||||
"id": uuid.UUID(aid),
|
||||
"training_category": None,
|
||||
"training_type_id": None,
|
||||
},
|
||||
{
|
||||
"id": uuid.UUID(bid),
|
||||
"training_category": None,
|
||||
"training_type_id": None,
|
||||
},
|
||||
]
|
||||
return [
|
||||
{
|
||||
"activity_log_id": uuid.UUID(aid),
|
||||
"training_parameter_id": 3,
|
||||
"key": "rpe",
|
||||
"data_type": "integer",
|
||||
"unit": None,
|
||||
|
|
@ -199,6 +265,280 @@ def test_enrich_sessions_batch():
|
|||
|
||||
sessions = [{"id": aid}, {"id": bid}]
|
||||
enrich_sessions_with_metrics(_Cur(), sessions)
|
||||
assert sessions[0]["session_metrics"][0]["value"] == 7
|
||||
assert sessions[0]["session_metrics"][0]["key"] == "rpe"
|
||||
assert sessions[0]["session_metrics"] == []
|
||||
assert sessions[1]["session_metrics"] == []
|
||||
|
||||
|
||||
def test_merge_column_backed_prefers_column_over_stale_eav():
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"key": "hr_avg",
|
||||
"data_type": "float",
|
||||
"unit": "bpm",
|
||||
"validation_rules": {},
|
||||
"source_field": "hr_avg",
|
||||
}
|
||||
]
|
||||
eav = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"key": "hr_avg",
|
||||
"data_type": "float",
|
||||
"unit": "bpm",
|
||||
"value": 99.0,
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"hr_avg": 140.0}, schema, eav)
|
||||
assert len(out) == 1
|
||||
assert out[0]["value"] == 140.0
|
||||
|
||||
|
||||
def test_merge_falls_back_to_eav_when_column_empty():
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"key": "hr_avg",
|
||||
"data_type": "float",
|
||||
"unit": "bpm",
|
||||
"validation_rules": {},
|
||||
"source_field": "hr_avg",
|
||||
}
|
||||
]
|
||||
eav = [
|
||||
{
|
||||
"training_parameter_id": 1,
|
||||
"key": "hr_avg",
|
||||
"data_type": "float",
|
||||
"unit": "bpm",
|
||||
"value": 99.0,
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"hr_avg": None}, schema, eav)
|
||||
assert len(out) == 1
|
||||
assert out[0]["value"] == 99.0
|
||||
|
||||
|
||||
def test_merge_ignores_eav_when_parameter_not_in_schema():
|
||||
"""Nur tcp/ttp-Schema zählt: verwaiste EAV-Zeilen erscheinen nicht in der effektiven Liste."""
|
||||
schema = []
|
||||
eav = [
|
||||
{
|
||||
"training_parameter_id": 2,
|
||||
"key": "custom_param",
|
||||
"data_type": "string",
|
||||
"unit": None,
|
||||
"value": "x",
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({}, schema, eav)
|
||||
assert out == []
|
||||
|
||||
|
||||
def test_merge_eav_primary_falls_back_to_legacy_hr_min_column():
|
||||
"""Kanon: min_hr ohne source_field / ohne EAV — Lesefallback Spalte hr_min."""
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 9,
|
||||
"key": "min_hr",
|
||||
"data_type": "integer",
|
||||
"unit": "bpm",
|
||||
"validation_rules": {},
|
||||
"source_field": None,
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, [])
|
||||
assert len(out) == 1
|
||||
assert out[0]["key"] == "min_hr"
|
||||
assert out[0]["value"] == 88
|
||||
|
||||
|
||||
def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both():
|
||||
"""Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min)."""
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 9,
|
||||
"key": "min_hr",
|
||||
"data_type": "integer",
|
||||
"unit": "bpm",
|
||||
"validation_rules": {},
|
||||
"source_field": None,
|
||||
}
|
||||
]
|
||||
eav = [
|
||||
{
|
||||
"training_parameter_id": 9,
|
||||
"key": "min_hr",
|
||||
"data_type": "integer",
|
||||
"unit": "bpm",
|
||||
"value": 100,
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav)
|
||||
assert len(out) == 1
|
||||
assert out[0]["value"] == 88
|
||||
|
||||
|
||||
def test_merge_registry_key_prefers_activity_log_column_over_eav():
|
||||
"""Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV."""
|
||||
schema = [
|
||||
{
|
||||
"training_parameter_id": 3,
|
||||
"key": "duration_min",
|
||||
"data_type": "float",
|
||||
"unit": "min",
|
||||
"validation_rules": {},
|
||||
"source_field": None,
|
||||
}
|
||||
]
|
||||
eav = [
|
||||
{
|
||||
"training_parameter_id": 3,
|
||||
"key": "duration_min",
|
||||
"data_type": "float",
|
||||
"unit": "min",
|
||||
"value": 99.0,
|
||||
}
|
||||
]
|
||||
out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav)
|
||||
assert len(out) == 1
|
||||
assert out[0]["value"] == 45.0
|
||||
|
||||
|
||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
||||
def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema):
|
||||
"""Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""
|
||||
mock_schema.return_value = [
|
||||
{
|
||||
"key": "avg_hr",
|
||||
"training_parameter_id": 42,
|
||||
"data_type": "integer",
|
||||
"validation_rules": {"min": 30, "max": 220},
|
||||
"source_field": "hr_avg",
|
||||
}
|
||||
]
|
||||
|
||||
class Cur:
|
||||
def __init__(self):
|
||||
self.asm_inserts = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
if "INSERT INTO activity_session_metrics" in sql:
|
||||
self.asm_inserts += 1
|
||||
|
||||
def fetchone(self):
|
||||
return {
|
||||
"profile_id": "00000000-0000-0000-0000-000000000001",
|
||||
"hr_avg": 130,
|
||||
}
|
||||
|
||||
cur = Cur()
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
{"avg_hr": 130},
|
||||
"cardio",
|
||||
1,
|
||||
)
|
||||
assert cur.asm_inserts == 0
|
||||
|
||||
|
||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
||||
def test_upsert_csv_writes_eav_when_source_field_but_column_empty(mock_schema):
|
||||
"""source_field gesetzt, activity_log-Spalte leer — Wert aus mapped nur in EAV (kein Registry-Patch)."""
|
||||
mock_schema.return_value = [
|
||||
{
|
||||
"key": "avg_hr",
|
||||
"training_parameter_id": 42,
|
||||
"data_type": "integer",
|
||||
"validation_rules": {"min": 30, "max": 220},
|
||||
"source_field": "hr_avg",
|
||||
}
|
||||
]
|
||||
|
||||
class Cur:
|
||||
def __init__(self):
|
||||
self.asm_inserts = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
if "INSERT INTO activity_session_metrics" in sql:
|
||||
self.asm_inserts += 1
|
||||
|
||||
def fetchone(self):
|
||||
return {
|
||||
"profile_id": "00000000-0000-0000-0000-000000000001",
|
||||
"hr_avg": None,
|
||||
}
|
||||
|
||||
cur = Cur()
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
{"avg_hr": 130},
|
||||
"cardio",
|
||||
1,
|
||||
)
|
||||
assert cur.asm_inserts == 1
|
||||
|
||||
|
||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema")
|
||||
def test_upsert_csv_writes_eav_when_no_source_field(mock_schema):
|
||||
mock_schema.return_value = [
|
||||
{
|
||||
"key": "custom_note",
|
||||
"training_parameter_id": 99,
|
||||
"data_type": "string",
|
||||
"validation_rules": {},
|
||||
"source_field": None,
|
||||
}
|
||||
]
|
||||
|
||||
class Cur:
|
||||
def __init__(self):
|
||||
self.asm_inserts = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
if "INSERT INTO activity_session_metrics" in sql:
|
||||
self.asm_inserts += 1
|
||||
|
||||
def fetchone(self):
|
||||
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
|
||||
|
||||
cur = Cur()
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
{"custom_note": "x"},
|
||||
"cardio",
|
||||
1,
|
||||
)
|
||||
assert cur.asm_inserts == 1
|
||||
|
||||
|
||||
@patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema", return_value=[])
|
||||
def test_upsert_csv_skips_eav_when_mapped_key_not_in_profile_schema(mock_resolve):
|
||||
"""Import-Mapping allein legt kein EAV an — Key muss in tcp/ttp (resolve) vorkommen."""
|
||||
class Cur:
|
||||
def __init__(self):
|
||||
self.asm_inserts = 0
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
if "INSERT INTO activity_session_metrics" in sql:
|
||||
self.asm_inserts += 1
|
||||
|
||||
def fetchone(self):
|
||||
return {"profile_id": "00000000-0000-0000-0000-000000000001", "hr_avg": None}
|
||||
|
||||
cur = Cur()
|
||||
upsert_session_metrics_from_csv_mapped(
|
||||
cur,
|
||||
"00000000-0000-0000-0000-000000000001",
|
||||
"00000000-0000-0000-0000-000000000002",
|
||||
{"stola": 12},
|
||||
"cardio",
|
||||
1,
|
||||
)
|
||||
assert cur.asm_inserts == 0
|
||||
|
|
|
|||
|
|
@ -440,6 +440,141 @@ a.analysis-split__nav-item {
|
|||
}
|
||||
}
|
||||
|
||||
/* Admin: Session-Metriken / Attributprofile — volle Breite, linksbündig (nicht globale 90px-Zahlfelder) */
|
||||
.activity-attribute-profiles .aaf-stack {
|
||||
max-width: 42rem;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
text-align: left;
|
||||
margin-bottom: 6px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-sublabel {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
text-align: left;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
text-align: left;
|
||||
margin: 6px 0 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-input,
|
||||
.activity-attribute-profiles textarea.aaf-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-family: var(--font);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text1);
|
||||
background: var(--surface2);
|
||||
border: 1.5px solid var(--border2);
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.activity-attribute-profiles textarea.aaf-input {
|
||||
resize: vertical;
|
||||
min-height: 4.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.activity-attribute-profiles .aaf-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.activity-attribute-profiles .aaf-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.activity-attribute-profiles .aaf-field-select {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.activity-attribute-profiles .aaf-field-select:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-field-select .form-label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
flex: unset;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-field-select .form-input,
|
||||
.activity-attribute-profiles .aaf-field-select select.form-input {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar .form-label {
|
||||
display: block;
|
||||
text-align: left;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
flex: unset;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar__grow {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar .form-input,
|
||||
.activity-attribute-profiles .aaf-toolbar select.form-input {
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
max-width: none;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar__compact {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-toolbar__compact .form-input,
|
||||
.activity-attribute-profiles .aaf-toolbar__compact select.form-input {
|
||||
width: 100%;
|
||||
min-width: 5rem;
|
||||
}
|
||||
.activity-attribute-profiles .aaf-inline-edit .form-input,
|
||||
.activity-attribute-profiles .aaf-inline-edit select.form-input {
|
||||
text-align: left;
|
||||
min-width: 4.5rem;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
/* Erfassung: Sub-Navigation (Mobil = Chips, Desktop = linke Spalte) */
|
||||
.capture-shell {
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -96,6 +96,77 @@ const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
|
|||
'training_subcategory',
|
||||
])
|
||||
|
||||
/** activity_log-Spalten, die bereits in EntryForm (Kopfzeile) bearbeitet werden — Profilfeld mit gleichem source_field nicht doppelt anzeigen. */
|
||||
const ENTRY_FORM_ACTIVITY_LOG_COLUMNS = new Set([
|
||||
'duration_min',
|
||||
'kcal_active',
|
||||
'hr_avg',
|
||||
'hr_max',
|
||||
'rpe',
|
||||
'notes',
|
||||
])
|
||||
|
||||
/**
|
||||
* Bindung Profilparameter ↔ Kopfzeile: Entweder source_field zeigt auf eine Kopfspalte,
|
||||
* oder der Parameter-key ist selbst eine Kopfspalte (häufig nach Migration / ohne source_field).
|
||||
* @returns {{ headlineCol: string, parameterKey: string } | null}
|
||||
*/
|
||||
function activitySchemaHeadlineBinding(s) {
|
||||
if (!s || !s.key) return null
|
||||
const sf = s.source_field != null ? String(s.source_field).trim() : ''
|
||||
if (sf && ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(sf)) {
|
||||
return { headlineCol: sf, parameterKey: s.key }
|
||||
}
|
||||
if (ENTRY_FORM_ACTIVITY_LOG_COLUMNS.has(s.key)) {
|
||||
return { headlineCol: s.key, parameterKey: s.key }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** training_parameters.category (siehe Migration 013); feste Reihenfolge der Wertegruppen */
|
||||
const TRAINING_PARAM_CATEGORY_ORDER = [
|
||||
'physical',
|
||||
'physiological',
|
||||
'performance',
|
||||
'subjective',
|
||||
'environmental',
|
||||
]
|
||||
|
||||
const TRAINING_PARAM_CATEGORY_LABEL_DE = {
|
||||
physical: 'Physisch / Bewegung',
|
||||
physiological: 'Physiologie',
|
||||
performance: 'Leistung',
|
||||
subjective: 'Subjektiv und Wahrnehmung',
|
||||
environmental: 'Umwelt',
|
||||
}
|
||||
|
||||
function compareActivityProfileSchemaRows(a, b) {
|
||||
const ca = (a.param_category && String(a.param_category).trim().toLowerCase()) || ''
|
||||
const cb = (b.param_category && String(b.param_category).trim().toLowerCase()) || ''
|
||||
const ia = TRAINING_PARAM_CATEGORY_ORDER.indexOf(ca)
|
||||
const ib = TRAINING_PARAM_CATEGORY_ORDER.indexOf(cb)
|
||||
const ra = ia === -1 ? 1000 : ia
|
||||
const rb = ib === -1 ? 1000 : ib
|
||||
if (ra !== rb) return ra - rb
|
||||
if (ca !== cb) return ca.localeCompare(cb, 'de')
|
||||
|
||||
const ga = (a.ui_group && String(a.ui_group).trim()) || ''
|
||||
const gb = (b.ui_group && String(b.ui_group).trim()) || ''
|
||||
if (ga !== gb) {
|
||||
if (!ga) return -1
|
||||
if (!gb) return 1
|
||||
return ga.localeCompare(gb, 'de')
|
||||
}
|
||||
const sa = Number(a.sort_order) || 0
|
||||
const sb = Number(b.sort_order) || 0
|
||||
if (sa !== sb) return sa - sb
|
||||
return String(a.key).localeCompare(String(b.key), 'de')
|
||||
}
|
||||
|
||||
function sortActivityProfileSchemaRows(rows) {
|
||||
return [...rows].sort(compareActivityProfileSchemaRows)
|
||||
}
|
||||
|
||||
function empty() {
|
||||
return {
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
|
|
@ -146,48 +217,110 @@ function buildMetricsPayload(schema, draft) {
|
|||
|
||||
function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
||||
const schemaList = Array.isArray(schema) ? schema : []
|
||||
const headlineDuplicateKeys = new Set(
|
||||
schemaList.filter((s) => activitySchemaHeadlineBinding(s) != null).map((s) => s.key),
|
||||
)
|
||||
const schemaForDisplay = schemaList.filter((s) => activitySchemaHeadlineBinding(s) == null)
|
||||
const metricRows = Array.isArray(metrics) ? metrics : []
|
||||
const schemaKeys = new Set(schemaList.map((s) => s.key))
|
||||
const orphanMetrics = metricRows.filter((row) => row && row.key && !schemaKeys.has(row.key))
|
||||
const schemaKeys = new Set(schemaForDisplay.map((s) => s.key))
|
||||
const orphanMetrics = metricRows.filter(
|
||||
(row) =>
|
||||
row &&
|
||||
row.key &&
|
||||
!schemaKeys.has(row.key) &&
|
||||
!headlineDuplicateKeys.has(row.key),
|
||||
)
|
||||
|
||||
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
|
||||
if (schemaForDisplay.length === 0 && orphanMetrics.length === 0) return null
|
||||
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
|
||||
|
||||
const sortedForDisplay = sortActivityProfileSchemaRows(schemaForDisplay)
|
||||
const profileFieldNodes = []
|
||||
let lastCategoryKey = null
|
||||
let lastUiGroup = null
|
||||
for (const s of sortedForDisplay) {
|
||||
const catRaw = (s.param_category && String(s.param_category).trim().toLowerCase()) || ''
|
||||
const catKey = catRaw || '_other'
|
||||
if (catKey !== lastCategoryKey) {
|
||||
lastCategoryKey = catKey
|
||||
lastUiGroup = null
|
||||
const catTitle =
|
||||
(catRaw && TRAINING_PARAM_CATEGORY_LABEL_DE[catRaw]) || s.param_category || 'Sonstige'
|
||||
profileFieldNodes.push(
|
||||
<div
|
||||
key={`prof-cat-${catKey}-${profileFieldNodes.length}`}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text2)',
|
||||
marginTop: profileFieldNodes.length ? 14 : 6,
|
||||
marginBottom: 6,
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
{catTitle}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
const ug = (s.ui_group && String(s.ui_group).trim()) || ''
|
||||
if (ug) {
|
||||
if (ug !== lastUiGroup) {
|
||||
lastUiGroup = ug
|
||||
profileFieldNodes.push(
|
||||
<div
|
||||
key={`prof-ug-${catKey}-${ug}-${profileFieldNodes.length}`}
|
||||
style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 4 }}
|
||||
>
|
||||
{ug}
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
lastUiGroup = null
|
||||
}
|
||||
profileFieldNodes.push(
|
||||
<div key={s.key} className="form-row">
|
||||
<label className="form-label">
|
||||
{s.name_de}
|
||||
{s.required ? ' *' : ''}
|
||||
{s.unit ? ` (${s.unit})` : ''}
|
||||
</label>
|
||||
{s.data_type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', marginRight: 'auto' }}
|
||||
checked={!!values[s.key]}
|
||||
onChange={(e) => set(s.key, e.target.checked)}
|
||||
/>
|
||||
) : s.data_type === 'integer' || s.data_type === 'float' ? (
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
step={s.data_type === 'integer' ? 1 : 'any'}
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="form-unit" />
|
||||
</div>,
|
||||
)
|
||||
}
|
||||
|
||||
const orphansSorted = [...orphanMetrics].sort((a, b) =>
|
||||
String(a.key).localeCompare(String(b.key), 'de'),
|
||||
)
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Weitere Kennwerte (Profil)</div>
|
||||
{schemaList.map((s) => (
|
||||
<div key={s.key} className="form-row">
|
||||
<label className="form-label">
|
||||
{s.name_de}
|
||||
{s.required ? ' *' : ''}
|
||||
{s.unit ? ` (${s.unit})` : ''}
|
||||
</label>
|
||||
{s.data_type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: 'auto', marginRight: 'auto' }}
|
||||
checked={!!values[s.key]}
|
||||
onChange={(e) => set(s.key, e.target.checked)}
|
||||
/>
|
||||
) : s.data_type === 'integer' || s.data_type === 'float' ? (
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
step={s.data_type === 'integer' ? 1 : 'any'}
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={values[s.key] ?? ''}
|
||||
onChange={(e) => set(s.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="form-unit" />
|
||||
</div>
|
||||
))}
|
||||
{profileFieldNodes}
|
||||
{orphanMetrics.length > 0 && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
|
||||
|
|
@ -195,7 +328,7 @@ function SessionMetricsFields({ schema, values, setValues, metrics }) {
|
|||
in activity_log) nicht ins Schema passen — nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
|
||||
Datenbank stehen.
|
||||
</div>
|
||||
{orphanMetrics.map((row) => {
|
||||
{orphansSorted.map((row) => {
|
||||
const disp =
|
||||
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
|
||||
? '—'
|
||||
|
|
@ -421,6 +554,12 @@ export default function ActivityPage() {
|
|||
const [categories, setCategories] = useState({}) // v9d: Training categories
|
||||
const [sessionDetail, setSessionDetail] = useState(null)
|
||||
const [metricDraft, setMetricDraft] = useState({})
|
||||
/** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */
|
||||
const editSchemaKeysPrevRef = useRef(new Set())
|
||||
const prevEditingIdRef = useRef(null)
|
||||
const [manualSchema, setManualSchema] = useState(null)
|
||||
const [manualMetricDraft, setManualMetricDraft] = useState({})
|
||||
const manualSchemaKeysPrevRef = useRef(new Set())
|
||||
const [sessionLoadError, setSessionLoadError] = useState(null)
|
||||
const [savingEdit, setSavingEdit] = useState(false)
|
||||
const [listLoadingMore, setListLoadingMore] = useState(false)
|
||||
|
|
@ -506,18 +645,31 @@ export default function ActivityPage() {
|
|||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||
}, [fetchMonthsChain])
|
||||
|
||||
useEffect(() => {
|
||||
editSchemaKeysPrevRef.current = new Set()
|
||||
}, [editing?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing?.id) {
|
||||
setSessionDetail(null)
|
||||
setMetricDraft({})
|
||||
setSessionLoadError(null)
|
||||
prevEditingIdRef.current = null
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
setSessionLoadError(null)
|
||||
if (prevEditingIdRef.current !== editing.id) {
|
||||
setSessionDetail(null)
|
||||
prevEditingIdRef.current = editing.id
|
||||
}
|
||||
;(async () => {
|
||||
try {
|
||||
const d = await api.getActivitySession(editing.id)
|
||||
const d = await api.getActivitySession(editing.id, {
|
||||
useFormSchema: true,
|
||||
training_category: editing.training_category,
|
||||
training_type_id: editing.training_type_id,
|
||||
})
|
||||
if (!cancelled) setSessionDetail(d)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
|
|
@ -527,25 +679,89 @@ export default function ActivityPage() {
|
|||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [editing?.id])
|
||||
}, [editing?.id, editing?.training_category, editing?.training_type_id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionDetail) {
|
||||
setMetricDraft({})
|
||||
return
|
||||
}
|
||||
const m = {}
|
||||
for (const row of sessionDetail.metrics || []) {
|
||||
m[row.key] = row.value
|
||||
}
|
||||
for (const s of sessionDetail.schema || []) {
|
||||
if (!(s.key in m)) {
|
||||
m[s.key] = s.data_type === 'boolean' ? false : ''
|
||||
const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key))
|
||||
const oldKeys = editSchemaKeysPrevRef.current
|
||||
|
||||
setMetricDraft((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const row of sessionDetail.metrics || []) {
|
||||
const k = row.key
|
||||
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
|
||||
continue
|
||||
}
|
||||
next[k] = row.value
|
||||
}
|
||||
}
|
||||
setMetricDraft(m)
|
||||
for (const s of sessionDetail.schema || []) {
|
||||
if (!(s.key in next)) {
|
||||
next[s.key] = s.data_type === 'boolean' ? false : ''
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
editSchemaKeysPrevRef.current = newKeys
|
||||
}, [sessionDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'add') {
|
||||
setManualSchema(null)
|
||||
setManualMetricDraft({})
|
||||
manualSchemaKeysPrevRef.current = new Set()
|
||||
return
|
||||
}
|
||||
const tid = form.training_type_id
|
||||
const cat = form.training_category
|
||||
if (tid == null && (cat == null || cat === '')) {
|
||||
setManualSchema(null)
|
||||
setManualMetricDraft({})
|
||||
manualSchemaKeysPrevRef.current = new Set()
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await api.getActivityAttributeSchema({
|
||||
training_category: cat || undefined,
|
||||
training_type_id: tid ?? undefined,
|
||||
})
|
||||
if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : [])
|
||||
} catch (err) {
|
||||
console.error('attribute-schema:', err)
|
||||
if (!cancelled) setManualSchema([])
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [tab, form.training_category, form.training_type_id])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'add' || !manualSchema) {
|
||||
return
|
||||
}
|
||||
const newKeys = new Set(manualSchema.map((s) => s.key))
|
||||
const oldKeys = manualSchemaKeysPrevRef.current
|
||||
|
||||
setManualMetricDraft((prev) => {
|
||||
const next = { ...prev }
|
||||
for (const s of manualSchema) {
|
||||
const k = s.key
|
||||
if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) {
|
||||
continue
|
||||
}
|
||||
if (!(k in next)) {
|
||||
next[k] = s.data_type === 'boolean' ? false : ''
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
manualSchemaKeysPrevRef.current = newKeys
|
||||
}, [tab, manualSchema])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
|
@ -565,7 +781,20 @@ export default function ActivityPage() {
|
|||
if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max)
|
||||
if(payload.rpe) payload.rpe = parseInt(payload.rpe)
|
||||
payload.source = 'manual'
|
||||
await api.createActivity(payload)
|
||||
const created = await api.createActivity(payload)
|
||||
if (manualSchema && manualSchema.length > 0 && created?.id) {
|
||||
try {
|
||||
const metrics = buildMetricsPayload(manualSchema, manualMetricDraft)
|
||||
await api.putActivityMetrics(created.id, { metrics })
|
||||
} catch (metErr) {
|
||||
console.error(metErr)
|
||||
setError(
|
||||
metErr.message ||
|
||||
'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.',
|
||||
)
|
||||
setTimeout(() => setError(null), 8000)
|
||||
}
|
||||
}
|
||||
setSaved(true)
|
||||
await load()
|
||||
await loadUsage() // Reload usage after save
|
||||
|
|
@ -624,7 +853,26 @@ export default function ActivityPage() {
|
|||
: timePayloadFromInput(payload.end_time)
|
||||
await api.updateActivity(editing.id, payload)
|
||||
if (sessionDetail?.schema?.length > 0) {
|
||||
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
|
||||
const draftForMetrics = { ...metricDraft }
|
||||
for (const s of sessionDetail.schema) {
|
||||
const bind = activitySchemaHeadlineBinding(s)
|
||||
if (!bind || !(s.key in draftForMetrics)) continue
|
||||
const rawCol =
|
||||
payload[bind.headlineCol] !== undefined ? payload[bind.headlineCol] : editing?.[bind.headlineCol]
|
||||
if (rawCol === undefined) continue
|
||||
if (s.data_type === 'boolean') {
|
||||
draftForMetrics[s.key] = !!rawCol
|
||||
} else if (s.data_type === 'integer') {
|
||||
const n = parseInt(String(rawCol), 10)
|
||||
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
|
||||
} else if (s.data_type === 'float') {
|
||||
const n = parseFloat(String(rawCol))
|
||||
draftForMetrics[s.key] = Number.isNaN(n) ? '' : n
|
||||
} else {
|
||||
draftForMetrics[s.key] = rawCol == null ? '' : String(rawCol)
|
||||
}
|
||||
}
|
||||
const metrics = buildMetricsPayload(sessionDetail.schema, draftForMetrics)
|
||||
await api.putActivityMetrics(editing.id, { metrics })
|
||||
}
|
||||
setEditing(null)
|
||||
|
|
@ -712,9 +960,23 @@ export default function ActivityPage() {
|
|||
<span>Training eintragen</span>
|
||||
{activityUsage && <UsageBadge {...activityUsage} />}
|
||||
</div>
|
||||
<EntryForm form={form} setForm={setForm}
|
||||
onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'}
|
||||
saving={saving} error={error} usage={activityUsage}/>
|
||||
<EntryForm
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
onSave={handleSave}
|
||||
saveLabel={saved ? '✓ Gespeichert!' : 'Speichern'}
|
||||
saving={saving}
|
||||
error={error}
|
||||
usage={activityUsage}
|
||||
formExtras={
|
||||
<SessionMetricsFields
|
||||
schema={manualSchema}
|
||||
metrics={[]}
|
||||
values={manualMetricDraft}
|
||||
setValues={setManualMetricDraft}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ const emptyParamForm = () => ({
|
|||
key: '',
|
||||
name_de: '',
|
||||
name_en: '',
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
category: 'physical',
|
||||
data_type: 'float',
|
||||
unit: '',
|
||||
|
|
@ -130,6 +132,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
key: paramForm.key.trim().toLowerCase(),
|
||||
name_de: paramForm.name_de.trim(),
|
||||
name_en: paramForm.name_en.trim(),
|
||||
description_de: paramForm.description_de.trim() || null,
|
||||
description_en: paramForm.description_en.trim() || null,
|
||||
category: paramForm.category,
|
||||
data_type: paramForm.data_type,
|
||||
unit: paramForm.unit.trim() || null,
|
||||
|
|
@ -153,6 +157,8 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
await api.adminUpdateTrainingParameter(editParam.id, {
|
||||
name_de: editParam.name_de.trim(),
|
||||
name_en: editParam.name_en.trim(),
|
||||
description_de: editParam.description_de?.trim() || null,
|
||||
description_en: editParam.description_en?.trim() || null,
|
||||
category: editParam.category,
|
||||
data_type: editParam.data_type,
|
||||
unit: editParam.unit?.trim() || null,
|
||||
|
|
@ -273,7 +279,7 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="capture-page">
|
||||
<div className="capture-page activity-attribute-profiles">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
|
||||
← Training (Hub)
|
||||
|
|
@ -302,6 +308,11 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
Nach Migration <strong>055</strong> werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
|
||||
<code>activity_log</code>-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
|
||||
</li>
|
||||
<li>
|
||||
<strong>KI:</strong> Bei eigenen / unklaren Metriken kurze <strong>Beschreibung DE/EN</strong> im Katalog
|
||||
pflegen — sie erscheinen in Export/Platzhalter-Kontext (<code>training_sessions_recent_json</code>,{' '}
|
||||
<code>{'{{training_parameters_glossary_md}}'}</code>).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
|
@ -367,70 +378,151 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div className="form-row">
|
||||
<label className="form-label">key</label>
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="z. B. avg_power"
|
||||
value={paramForm.key}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">name_de / name_en</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_de}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.name_en}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe / Datentyp</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.category}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={paramForm.data_type}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit / source_field</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.unit}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={paramForm.source_field}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
||||
/>
|
||||
<div className="aaf-stack">
|
||||
<div className="aaf-field">
|
||||
<label className="aaf-label" htmlFor="aaf-new-key">
|
||||
Technischer Schlüssel (key)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-new-key"
|
||||
className="aaf-input"
|
||||
placeholder="z. B. avg_power"
|
||||
value={paramForm.key}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Bezeichnung</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-name-de">
|
||||
Deutsch
|
||||
</label>
|
||||
<input
|
||||
id="aaf-new-name-de"
|
||||
className="aaf-input"
|
||||
value={paramForm.name_de}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_de: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-name-en">
|
||||
English
|
||||
</label>
|
||||
<input
|
||||
id="aaf-new-name-en"
|
||||
className="aaf-input"
|
||||
value={paramForm.name_en}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-desc-de">
|
||||
Deutsch
|
||||
</label>
|
||||
<textarea
|
||||
id="aaf-new-desc-de"
|
||||
className="aaf-input"
|
||||
rows={3}
|
||||
placeholder="Was bedeutet der Wert? Einheit, Skala, Herkunft …"
|
||||
value={paramForm.description_de}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, description_de: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-desc-en">
|
||||
English
|
||||
</label>
|
||||
<textarea
|
||||
id="aaf-new-desc-en"
|
||||
className="aaf-input"
|
||||
rows={3}
|
||||
placeholder="Short meaning for prompts and EN contexts"
|
||||
value={paramForm.description_en}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, description_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Gruppe und Datentyp</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-cat">
|
||||
Parameter-Gruppe
|
||||
</label>
|
||||
<select
|
||||
id="aaf-new-cat"
|
||||
className="aaf-input"
|
||||
value={paramForm.category}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-new-dtype">
|
||||
Datentyp
|
||||
</label>
|
||||
<select
|
||||
id="aaf-new-dtype"
|
||||
className="aaf-input"
|
||||
value={paramForm.data_type}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<label className="aaf-label" htmlFor="aaf-new-unit">
|
||||
Einheit (optional)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-new-unit"
|
||||
className="aaf-input"
|
||||
placeholder="z. B. W, bpm, min"
|
||||
value={paramForm.unit}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, unit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<label className="aaf-label" htmlFor="aaf-new-source-field">
|
||||
Quell-Spalte in activity_log (source_field, optional)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-new-source-field"
|
||||
className="aaf-input"
|
||||
placeholder="z. B. hr_avg — Spaltenname der Trainingseinheit"
|
||||
autoComplete="off"
|
||||
value={paramForm.source_field}
|
||||
onChange={(e) => setParamForm((f) => ({ ...f, source_field: e.target.value }))}
|
||||
/>
|
||||
<p className="aaf-hint">
|
||||
Wenn gesetzt, wird der Messwert beim Anzeigen und Zusammenführen mit EAV primär aus dieser
|
||||
Spalte der Einheit gelesen (nicht aus der EAV-Tabelle). Leer lassen, wenn der Wert nur über
|
||||
EAV oder Standard-Spalten kommt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
|
||||
|
|
@ -448,63 +540,138 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
style={{
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div className="card-title" style={{ fontSize: 14 }}>
|
||||
Bearbeiten: <code>{editParam.key}</code>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">name_de / name_en</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.name_de || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.name_en || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Gruppe / Typ</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editParam.category}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={editParam.data_type}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Einheit / source_field</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.unit || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editParam.source_field || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
|
||||
/>
|
||||
<div className="aaf-stack">
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Bezeichnung</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-name-de">
|
||||
Deutsch
|
||||
</label>
|
||||
<input
|
||||
id="aaf-edit-name-de"
|
||||
className="aaf-input"
|
||||
value={editParam.name_de || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_de: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-name-en">
|
||||
English
|
||||
</label>
|
||||
<input
|
||||
id="aaf-edit-name-en"
|
||||
className="aaf-input"
|
||||
value={editParam.name_en || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, name_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Beschreibung (optional, für KI / Export)</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-de">
|
||||
Deutsch
|
||||
</label>
|
||||
<textarea
|
||||
id="aaf-edit-desc-de"
|
||||
className="aaf-input"
|
||||
rows={3}
|
||||
value={editParam.description_de || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, description_de: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-desc-en">
|
||||
English
|
||||
</label>
|
||||
<textarea
|
||||
id="aaf-edit-desc-en"
|
||||
className="aaf-input"
|
||||
rows={3}
|
||||
value={editParam.description_en || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, description_en: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<span className="aaf-label">Gruppe und Datentyp</span>
|
||||
<div className="aaf-split">
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-cat">
|
||||
Parameter-Gruppe
|
||||
</label>
|
||||
<select
|
||||
id="aaf-edit-cat"
|
||||
className="aaf-input"
|
||||
value={editParam.category}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, category: e.target.value }))}
|
||||
>
|
||||
{PARAM_GROUP.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="aaf-sublabel" htmlFor="aaf-edit-dtype">
|
||||
Datentyp
|
||||
</label>
|
||||
<select
|
||||
id="aaf-edit-dtype"
|
||||
className="aaf-input"
|
||||
value={editParam.data_type}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, data_type: e.target.value }))}
|
||||
>
|
||||
{DATA_TYPES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<label className="aaf-label" htmlFor="aaf-edit-unit">
|
||||
Einheit (optional)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-edit-unit"
|
||||
className="aaf-input"
|
||||
placeholder="z. B. W, bpm, min"
|
||||
value={editParam.unit || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, unit: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="aaf-field">
|
||||
<label className="aaf-label" htmlFor="aaf-edit-source-field">
|
||||
Quell-Spalte in activity_log (source_field, optional)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-edit-source-field"
|
||||
className="aaf-input"
|
||||
placeholder="z. B. hr_avg"
|
||||
autoComplete="off"
|
||||
value={editParam.source_field || ''}
|
||||
onChange={(e) => setEditParam((p) => ({ ...p, source_field: e.target.value }))}
|
||||
/>
|
||||
<p className="aaf-hint">
|
||||
Optional: Name der <code>activity_log</code>-Spalte, aus der dieser Parameter beim Lesen zuerst
|
||||
befüllt wird (kanonisch vor EAV). Leer, wenn nur EAV oder implizites Spalten-Mapping.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
|
||||
<input
|
||||
|
|
@ -582,13 +749,15 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
{tab === 'category' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainings-Kategorie</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Kategorie</label>
|
||||
<div className="aaf-field-select">
|
||||
<label className="form-label" htmlFor="aaf-cat-pick">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
id="aaf-cat-pick"
|
||||
className="form-input"
|
||||
value={selCategory}
|
||||
onChange={(e) => setSelCategory(e.target.value)}
|
||||
style={{ maxWidth: 280 }}
|
||||
>
|
||||
{categoryKeys.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
|
|
@ -597,10 +766,13 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<div className="aaf-toolbar">
|
||||
<div className="aaf-toolbar__grow">
|
||||
<label className="form-label" htmlFor="aaf-cat-param">
|
||||
Parameter
|
||||
</label>
|
||||
<select
|
||||
id="aaf-cat-param"
|
||||
className="form-input"
|
||||
value={catAdd.training_parameter_id}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
|
|
@ -613,17 +785,22 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<div className="aaf-toolbar__compact">
|
||||
<label className="form-label" htmlFor="aaf-cat-sort">
|
||||
Sortierung
|
||||
</label>
|
||||
<input
|
||||
id="aaf-cat-sort"
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={catAdd.sort_order}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
|
||||
<label
|
||||
className="aaf-toolbar__compact"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 14, paddingBottom: 4 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={catAdd.required}
|
||||
|
|
@ -631,11 +808,14 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
/>
|
||||
Pflicht
|
||||
</label>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<div className="aaf-toolbar__compact">
|
||||
<label className="form-label" htmlFor="aaf-cat-uigroup">
|
||||
ui_group
|
||||
</label>
|
||||
<input
|
||||
id="aaf-cat-uigroup"
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
placeholder="optional"
|
||||
value={catAdd.ui_group}
|
||||
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
|
|
@ -655,21 +835,20 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
}}
|
||||
>
|
||||
{editingCatId === l.id ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
||||
<div className="aaf-inline-edit" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
|
||||
<span style={{ flex: '1 1 200px' }}>
|
||||
<strong>{l.parameter_key}</strong> · {l.parameter_name_de}
|
||||
</span>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 72 }}
|
||||
value={catDraft.sort_order}
|
||||
onChange={(e) => setCatDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 14, paddingBottom: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!catDraft.required}
|
||||
|
|
@ -679,7 +858,6 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
placeholder="ui_group"
|
||||
value={catDraft.ui_group}
|
||||
onChange={(e) => setCatDraft((d) => ({ ...d, ui_group: e.target.value }))}
|
||||
|
|
@ -739,13 +917,15 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
{tab === 'type' && (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Zuordnung: Trainingstyp (Zusatz / Override)</div>
|
||||
<div className="form-row">
|
||||
<label className="form-label">Trainingstyp</label>
|
||||
<div className="aaf-field-select">
|
||||
<label className="form-label" htmlFor="aaf-type-pick">
|
||||
Trainingstyp
|
||||
</label>
|
||||
<select
|
||||
id="aaf-type-pick"
|
||||
className="form-input"
|
||||
value={selTypeId}
|
||||
onChange={(e) => setSelTypeId(e.target.value)}
|
||||
style={{ maxWidth: 420 }}
|
||||
>
|
||||
{flatTypes.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
|
|
@ -754,10 +934,13 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ alignItems: 'flex-end', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<label className="form-label">Parameter</label>
|
||||
<div className="aaf-toolbar">
|
||||
<div className="aaf-toolbar__grow">
|
||||
<label className="form-label" htmlFor="aaf-type-param">
|
||||
Parameter
|
||||
</label>
|
||||
<select
|
||||
id="aaf-type-param"
|
||||
className="form-input"
|
||||
value={typeAdd.training_parameter_id}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
|
||||
|
|
@ -770,21 +953,25 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">sort (leer=Erben)</label>
|
||||
<div className="aaf-toolbar__compact">
|
||||
<label className="form-label" htmlFor="aaf-type-sort">
|
||||
Sortierung (leer = erben)
|
||||
</label>
|
||||
<input
|
||||
id="aaf-type-sort"
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 80 }}
|
||||
value={typeAdd.sort_order}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">Pflicht (leer=Erben)</label>
|
||||
<div className="aaf-toolbar__compact">
|
||||
<label className="form-label" htmlFor="aaf-type-req">
|
||||
Pflicht (leer = erben)
|
||||
</label>
|
||||
<select
|
||||
id="aaf-type-req"
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
value={typeAdd.required}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
|
||||
>
|
||||
|
|
@ -793,11 +980,14 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
<option value="false">nein</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label">ui_group</label>
|
||||
<div className="aaf-toolbar__compact">
|
||||
<label className="form-label" htmlFor="aaf-type-uigroup">
|
||||
ui_group
|
||||
</label>
|
||||
<input
|
||||
id="aaf-type-uigroup"
|
||||
className="form-input"
|
||||
style={{ width: 120 }}
|
||||
placeholder="optional"
|
||||
value={typeAdd.ui_group}
|
||||
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
|
||||
/>
|
||||
|
|
@ -817,23 +1007,21 @@ export default function AdminActivityAttributeProfilesPage() {
|
|||
}}
|
||||
>
|
||||
{editingTypeId === l.id ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
|
||||
<div className="aaf-inline-edit" style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'flex-end' }}>
|
||||
<span style={{ flex: '1 1 200px' }}>
|
||||
<strong>{l.parameter_key}</strong>
|
||||
</span>
|
||||
<div>
|
||||
<label className="form-label">sort</label>
|
||||
<label className="form-label">Sortierung</label>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 72 }}
|
||||
value={typeDraft.sort_order}
|
||||
onChange={(e) => setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: 100 }}
|
||||
value={typeDraft.required}
|
||||
onChange={(e) => setTypeDraft((d) => ({ ...d, required: e.target.value }))}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -352,7 +352,39 @@ export const api = {
|
|||
adminDeleteTrainingTypeParameter: (id) =>
|
||||
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
|
||||
/**
|
||||
* @param {string} id
|
||||
* @param {{ useFormSchema?: boolean, training_category?: string | null, training_type_id?: number | null }} [opts]
|
||||
*/
|
||||
getActivitySession: (id, opts = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
if (opts.useFormSchema) {
|
||||
q.set('use_form_schema', 'true')
|
||||
if (opts.training_category != null && opts.training_category !== '') {
|
||||
q.set('training_category', String(opts.training_category))
|
||||
}
|
||||
if (opts.training_type_id != null && opts.training_type_id !== '') {
|
||||
q.set('training_type_id', String(opts.training_type_id))
|
||||
}
|
||||
}
|
||||
const qs = q.toString()
|
||||
return req(`/activity/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
/**
|
||||
* Attributprofil ohne Session (manuelle Erfassung / Vorschau).
|
||||
* @param {{ training_category?: string | null, training_type_id?: number | null }} [params]
|
||||
*/
|
||||
getActivityAttributeSchema: (params = {}) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params.training_category != null && params.training_category !== '') {
|
||||
q.set('training_category', String(params.training_category))
|
||||
}
|
||||
if (params.training_type_id != null && params.training_type_id !== '') {
|
||||
q.set('training_type_id', String(params.training_type_id))
|
||||
}
|
||||
const qs = q.toString()
|
||||
return req(`/activity/attribute-schema${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
putActivityMetrics: (id, body) =>
|
||||
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user