Erste Version - Universal CSV Importer für EAV und activity_log #85

Merged
Lars merged 17 commits from develop into main 2026-04-15 11:46:31 +02:00
38 changed files with 4563 additions and 471 deletions

View File

@ -113,6 +113,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
| `TRAINING_PROFILE_RESOLVER_LAYER1.md` | Training-Resolver Schicht 1 |
| `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 |
| `V9D_PHASE2_VITALS_SLEEP.md` | v9d Vitalwerte/Schlaf (Release-Bezug) |
---

View File

@ -0,0 +1,122 @@
# Activity Session Metrics (EAV) Umsetzungs- & Agent-Guide
**Stand:** 2026-04-14
**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.
---
## 1. Produktions-Migrationen (Pflicht)
- **Nur additive Änderungen** bis zur Stabilisierung: neue Tabellen/Spalten **nullable**, kein `DROP COLUMN` / `DELETE` von Altbestand in derselben Story.
- Neue Migrationen: **`backend/migrations/054_*.sql`** (nächste freie Nummer nach 053 einhalten).
- **Prod-Checkliste vor Deploy:**
1. Backup / Snapshot der DB.
2. Migration auf **Kopie** der Prod-DB laufen lassen; Container-Start (`db_init`) verifizieren.
3. Stichprobe: `activity_log`-Zeilen unverändert; neue Tabellen leer oder nur Seed.
- **Datenhaltung:** Bestehende Spalten in `activity_log` bleiben **Quelle für Alt-Daten**; EAV (`activity_session_metrics`) ist der **kanonische Ort für konfigurierte Session-Metriken**, sobald geschrieben. Backfill Altspalten → EAV ist **separater Schritt** (siehe §6).
---
## 2. Datenmodell (Ist nach Migration 054)
| Tabelle | Zweck |
|---------|--------|
| `training_parameters` | Katalog messbarer Größen (`key`, `data_type`, `unit`, `validation_rules`, …) bereits Migration 013; Admin-API ergänzt. |
| `training_category_parameter` | Welche Parameter für welche **`training_types.category`** (z. B. `cardio`) gelten: `sort_order`, `required`, `ui_group`. |
| `training_type_parameter` | Zusatzparameter oder **Overrides** pro **`training_types.id`**: `sort_order`, `required`, `ui_group` (NULL = von Kategorie erben). |
| `activity_session_metrics` | EAV: `(activity_log_id, training_parameter_id)` eindeutig; genau eine Wertspalte `value_num` / `value_int` / `value_text` / `value_bool`. |
| `activity_log` | **Neu:** `started_at`, `ended_at` (`TIMESTAMPTZ`, nullable) für spätere Dedupe/Intervalle; **kein** Pflichtfeld in v1. |
**Merge-Logik effektives Schema** (Layer 1, eine Funktion):
1. Kategorie ermitteln: aus Zeile `training_category` oder aus `training_types.category` via `training_type_id`.
2. Basis = alle Zeilen `training_category_parameter` für diese Kategorie, Join auf `training_parameters` (aktiv).
3. Für jeden Eintrag in `training_type_parameter` zum gewählten Typ: gleiche `training_parameter_id` → Overrides anwenden; nur im Typ vorhanden → anhängen.
4. Sortierung: `sort_order` aufsteigend, dann `key`.
---
## 3. Layer 1 Kanonische Module
| 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`. |
**Regeln für Agenten:**
- **Keine** zweite Implementierung derselben Merge- oder Validierungslogik in Routern.
- 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`.
---
## 4. API (Ist / geplant)
### Admin (`require_admin`)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET/POST | `/api/admin/training-parameters` | Katalog lesen / Parameter anlegen |
| PUT/DELETE | `/api/admin/training-parameters/{id}` | Aktualisieren / Soft-deaktivieren (`is_active`) |
| GET | `/api/admin/training-category-parameters?category=` | Zuordnungen Kategorie |
| POST | `/api/admin/training-category-parameters` | Zuordnung anlegen |
| DELETE | `/api/admin/training-category-parameters/{id}` | Zuordnung entfernen |
| GET | `/api/admin/training-type-parameters?training_type_id=` | Zuordnungen Typ |
| POST | `/api/admin/training-type-parameters` | Zuordnung anlegen |
| DELETE | `/api/admin/training-type-parameters/{id}` | Zuordnung entfernen |
Router: `backend/routers/admin_training_parameters.py`, `backend/routers/admin_activity_attribute_profiles.py`.
### Nutzer (`require_auth`)
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/activity/{eid}` | Session-Kopf + `schema` + `metrics` (Layer 1) |
| PUT | `/api/activity/{eid}/metrics` | **Voller Ersatz** der EAV-Metriken für diese Session (Liste `{parameter_key, value}`) |
`ActivityEntry` unverändert für bestehende Create/Update-Routen; optionale Erweiterung um `started_at`/`ended_at` in späterem Schritt.
---
## 5. Agent-Checkliste (nächste Iterationen)
- [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`.
- [ ] Dedupe Polar/Apple: nach stabilen `started_at`/`ended_at` + Policy (eigenes Issue).
---
## 6. Backfill (nicht in Migration 054)
Separates Skript oder Migration **055+**, wenn fachlich freigegeben:
- Pro aktivem `training_parameter` mit gesetztem `source_field`: Wert aus `activity_log` lesen, in EAV schreiben, wenn noch keine Zeile existiert.
- Idempotent (`ON CONFLICT DO NOTHING` oder Upsert-Regel dokumentieren).
---
## 7. Automatische Tests (pytest, ohne DB)
Aus **`backend/`**:
```bash
python -m pytest tests/test_activity_session_metrics.py -v
```
Abdeckung: reine Merge-Logik (`merge_parameter_schema_rows`), Validierung (`_validate_single_value`), `resolve_activity_attribute_schema` mit Mock-Cursor, `enrich_sessions_with_metrics` mit Mock-Cursor.
---
## 8. Referenzen
- Migration 013: `training_parameters`
- 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`
---
**Version:** 1.1 · Bei Schema- oder API-Änderungen dieses Dokument und ggf. `CLAUDE.md` Kurzverweis aktualisieren.

View File

@ -12,6 +12,7 @@
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
> | **Agent-Einstieg** | **`.claude/README.md`** |
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
## Claude Code Verantwortlichkeiten
@ -115,6 +116,13 @@ frontend/src/
- **Gitea #75** (offen): Zucker/Ballaststoffe/Lebensmittelqualität, automatisches Lebensmittelprofil, später Mahlzeiten-Timing/Abgleich mit Training — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/75
- **`nutrition_score`:** Registry in `backend/placeholder_registrations/nutrition_score.py`, Import in `placeholder_registrations/__init__.py`; Legacy-Duplikat unter „Scores“ im Platzhalter-Katalog entfernt.
### Updates (14.04.2026 - Activity Session Metrics EAV, Kern-Backend)
- **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).
- **Frontend:** Admin `/admin/activity-attribute-profiles`; Aktivität → Verlauf → Bearbeiten: Profil-Kennwerte; `api.js` ergänzt.
### Updates (11.04.2026 - Ernährung: TDEE, Bilanz, Kalorien-Score)
- **`data_layer/nutrition_metrics.py`:** TDEE für Bilanz: primär **MifflinSt Jeor BMR × PAL 1,55**, wenn Profil (Größe, Geschlecht, DOB) und Gewicht vorhanden; sonst Fallback **kg × 32,5** (`estimate_tdee_kcal_from_latest_weight`). `get_energy_balance_data` / `calculate_energy_balance_7d` nutzen **tägliche kcal-Summen**. **`_score_calorie_adherence`** (Komponente von `calculate_nutrition_score`) wertet die 7-Tage-Bilanz nach **`profiles.goal_mode`** aus (weight_loss vs. strength/recomposition vs. maintenance/health/endurance).

View File

@ -57,6 +57,18 @@ def _split_first_lines(text: str, max_lines: int = 5) -> List[str]:
return lines
def canonical_csv_header_label(name: str | None) -> str:
"""
Einheitlicher Spalten-Key für Analyse (Vorlage/Dialog), Import und Signatur.
BOM und NBSP (häufig in Excel/Apple-Exporten) werden vereinheitlicht, damit
field_mappings exakt zu DictReader-Zeilen passt.
"""
if name is None:
return ""
s = str(name).replace("\ufeff", "").replace("\u00a0", " ").strip()
return s
def parse_csv_sample(
text: str,
delimiter: str | None = None,
@ -85,7 +97,7 @@ def parse_csv_sample(
return [], [], delim
if has_header:
headers = [h.strip() for h in rows_raw[0]]
headers = [canonical_csv_header_label(h) for h in rows_raw[0]]
data = rows_raw[1 : 1 + max_data_rows]
else:
n = len(rows_raw[0])
@ -103,7 +115,7 @@ def parse_csv_sample(
def normalize_header_for_signature(name: str) -> str:
s = name.strip().lower()
s = canonical_csv_header_label(name).lower()
s = re.sub(r"\s+", "_", s)
s = re.sub(r"[^a-z0-9_äöüß().%-]+", "_", s)
return s.strip("_")
@ -111,7 +123,9 @@ def normalize_header_for_signature(name: str) -> str:
def column_signature(headers: List[str]) -> List[str]:
"""Sortierte normalisierte Spaltennamen für Signatur-Vergleich."""
return sorted({normalize_header_for_signature(h) for h in headers if h is not None and str(h).strip()})
return sorted(
{normalize_header_for_signature(h) for h in headers if h is not None and canonical_csv_header_label(str(h))}
)
def headers_signature_match_score(sig_csv: List[str], sig_template: List[str]) -> float:
@ -178,12 +192,6 @@ def get_csv_import_limits(conn_row: dict | None) -> dict[str, int]:
return defaults
def _strip_header_key(k: str | None) -> str:
if k is None:
return ""
return str(k).strip().removeprefix("\ufeff")
def iter_csv_dict_rows(
text: str,
delimiter: str,
@ -205,4 +213,8 @@ def iter_csv_dict_rows(
continue
if not any(v and str(v).strip() for v in row.values()):
continue
yield {_strip_header_key(k): (v or "").strip() for k, v in row.items() if _strip_header_key(k)}
yield {
canonical_csv_header_label(k): (v or "").strip()
for k, v in row.items()
if canonical_csv_header_label(k)
}

View File

@ -23,14 +23,6 @@ from csv_parser.type_converter import build_row_after_mapping
logger = logging.getLogger(__name__)
try:
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
_EVALUATION_AVAILABLE = True
except Exception: # pragma: no cover
_evaluate_and_save_activity = None
_EVALUATION_AVAILABLE = False
def _resolve_training_type_for_activity(cur, activity_type: str, profile_id: str):
"""Lazy import — gleicher DB-Cursor wie der Import (kein verschachteltes get_db / Pool-Deadlock)."""
@ -814,6 +806,17 @@ def _import_activity(
error_details: list,
affected_ids: dict,
) -> dict[str, int]:
from data_layer.activity_time_normalize import normalize_activity_start
from data_layer.activity_persistence_orchestrator import (
activity_csv_registry_updates_from_mapped,
find_activity_duplicate_id,
insert_activity_csv_minimal,
new_activity_id,
run_activity_post_write_hooks_import,
update_activity_columns,
)
from data_layer.activity_session_metrics import upsert_session_metrics_from_csv_mapped
rows_total = 0
inserted = 0
updated = 0
@ -885,6 +888,7 @@ def _import_activity(
wtype = str(activity_type).strip()
iso = date_d.isoformat()
_, workout_start_t = normalize_activity_start(start_key)
# Pro Zeile: bei SQL-Fehler sonst „current transaction is aborted“ bis Xact-Ende.
cur.execute("SAVEPOINT csv_activity_row")
@ -892,113 +896,79 @@ def _import_activity(
training_type_id, training_category, training_subcategory = _resolve_training_type_for_activity(
cur, wtype, profile_id
)
cur.execute(
"""
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s AND start_time = %s
""",
(profile_id, iso, start_key),
)
existing = cur.fetchone()
registry_updates = activity_csv_registry_updates_from_mapped(mapped)
existing_id = find_activity_duplicate_id(cur, profile_id, iso, workout_start_t)
if existing:
eid = existing["id"]
cur.execute(
"""
UPDATE activity_log
SET end_time = %s,
activity_type = %s,
duration_min = %s,
kcal_active = %s,
kcal_resting = %s,
hr_avg = %s,
hr_max = %s,
distance_km = %s,
training_type_id = %s,
training_category = %s,
training_subcategory = %s,
source = 'csv'
WHERE id = %s
RETURNING id
""",
(
end_str or None,
wtype,
duration_min,
kcal_a,
kcal_r,
hr_a,
hr_m,
dist,
training_type_id,
training_category,
training_subcategory,
eid,
),
)
row = cur.fetchone()
if existing_id:
upd = {
"start_time": workout_start_t,
"end_time": end_str or None,
"activity_type": wtype,
"duration_min": duration_min,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"hr_avg": hr_a,
"hr_max": hr_m,
"distance_km": dist,
"training_type_id": training_type_id,
"training_category": training_category,
"training_subcategory": training_subcategory,
"source": "csv",
}
upd.update(registry_updates)
update_activity_columns(cur, profile_id, existing_id, upd)
updated += 1
if row and row.get("id"):
affected_ids["activity_log"].append(str(row["id"]))
aid = eid
affected_ids["activity_log"].append(str(existing_id))
aid = existing_id
else:
eid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO activity_log (
id, profile_id, date, start_time, end_time, activity_type, duration_min,
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
source, training_type_id, training_category, training_subcategory, created
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s,%s,%s,CURRENT_TIMESTAMP)
RETURNING id
""",
(
eid,
profile_id,
iso,
start_key,
end_str or None,
wtype,
duration_min,
kcal_a,
kcal_r,
hr_a,
hr_m,
dist,
training_type_id,
training_category,
training_subcategory,
),
eid = new_activity_id()
insert_activity_csv_minimal(
cur,
profile_id,
eid,
date_iso=iso,
start_time=workout_start_t,
end_time=end_str or None,
activity_type=wtype,
duration_min=duration_min,
kcal_active=kcal_a,
kcal_resting=kcal_r,
hr_avg=hr_a,
hr_max=hr_m,
distance_km=dist,
training_type_id=training_type_id,
training_category=training_category,
training_subcategory=training_subcategory,
source="csv",
)
row = cur.fetchone()
inserted += 1
new_entries += 1
if row and row.get("id"):
affected_ids["activity_log"].append(str(row["id"]))
affected_ids["activity_log"].append(str(eid))
aid = eid
if registry_updates:
update_activity_columns(cur, profile_id, aid, registry_updates)
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
try:
activity_dict = {
"id": aid,
"profile_id": profile_id,
"date": iso,
"training_type_id": training_type_id,
"duration_min": duration_min,
"hr_avg": hr_a,
"hr_max": hr_m,
"distance_km": dist,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None,
}
_evaluate_and_save_activity(cur, aid, activity_dict, training_type_id, profile_id)
except Exception as eval_err:
logger.warning("[csv activity] Auto-Eval fehlgeschlagen: %s", eval_err)
run_activity_post_write_hooks_import(
cur,
profile_id,
str(aid),
workout_date=iso,
training_type_id=training_type_id,
duration_min=duration_min,
hr_avg=hr_a,
hr_max=hr_m,
distance_km=dist,
kcal_active=kcal_a,
kcal_resting=kcal_r,
)
upsert_session_metrics_from_csv_mapped(
cur,
profile_id,
str(aid),
mapped,
training_category,
training_type_id,
)
cur.execute("RELEASE SAVEPOINT csv_activity_row")
except Exception as e:
try:

View File

@ -37,12 +37,16 @@ def validate_import_row_processing(
module: str,
spec: Mapping[str, Any],
field_mappings: Mapping[str, Any],
cur=None,
) -> None:
"""Wirft ValueError bei ungültiger Konfiguration."""
mod = get_module_definition(module)
if not mod:
raise ValueError(f"Unbekanntes Modul: {module}")
allowed = set(mod.get("fields") or [])
if module == "activity" and cur is not None:
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
allowed.update(str(r["key"]) for r in cur.fetchall())
fm_targets = {str(v) for v in field_mappings.values() if v and v not in ("-", "_skip")}
group_by = spec.get("group_by") or []

View File

@ -127,13 +127,19 @@ def _match_seed_to_db_field(header: str, seed_fm: Mapping[str, str]) -> str | No
return None
def _alias_suggest(norm: str, module: str, used: set[str]) -> str | None:
def _alias_suggest(
norm: str,
module: str,
used: set[str],
*,
field_order: list[str] | None = None,
) -> str | None:
aliases = _MODULE_HEADER_ALIASES.get(module, {})
mod = get_module_definition(module)
if not mod:
return None
field_order = list(mod["fields"].keys())
for db_field in field_order:
order = field_order if field_order is not None else list(mod["fields"].keys())
for db_field in order:
if db_field in used:
continue
tokens = aliases.get(db_field, frozenset())
@ -152,6 +158,8 @@ def suggest_field_mappings(
headers: list[str],
module: str,
seed_fm: Mapping[str, str] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, str]:
"""
Mappt jede CSV-Spalte (Roh-Header als Key) auf DB-Feld oder '-'.
@ -164,13 +172,16 @@ def suggest_field_mappings(
if not mod:
return {h: "-" for h in headers}
fields_map = dict(effective_fields) if effective_fields is not None else dict(mod["fields"])
field_order = list(fields_map.keys())
fm: dict[str, str] = {h: "-" for h in headers}
used: set[str] = set()
if seed_fm:
for h in headers:
db = _match_seed_to_db_field(h, seed_fm)
if db and db not in used:
if db and db not in used and db in fields_map:
fm[h] = db
used.add(db)
@ -178,7 +189,7 @@ def suggest_field_mappings(
if fm[h] != "-":
continue
norm = _norm_key(h)
db = _alias_suggest(norm, module, used)
db = _alias_suggest(norm, module, used, field_order=field_order)
if db:
fm[h] = db
used.add(db)
@ -190,6 +201,8 @@ def build_type_conversions_for_mapping(
module: str,
field_mappings: Mapping[str, str],
seed_tc: Mapping[str, Any] | None = None,
*,
effective_fields: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""type_conversions nur für zugewiesene Zielfelder; Seed überschreibt Defaults."""
if module == "sleep":
@ -198,6 +211,7 @@ def build_type_conversions_for_mapping(
defaults = _DEFAULT_TYPE_CONVERSIONS.get(module, {})
out: dict[str, Any] = {}
targets = {v for v in field_mappings.values() if v and v not in ("-", "_skip")}
field_meta = dict(effective_fields) if effective_fields is not None else None
if seed_tc:
for k, v in seed_tc.items():
@ -208,6 +222,20 @@ def build_type_conversions_for_mapping(
if t not in out and t in defaults:
out[t] = deepcopy(defaults[t])
for t in sorted(targets):
if t in out:
continue
finfo = (field_meta or {}).get(t) if field_meta else None
if not finfo:
continue
typ = finfo.get("type")
if typ == "int":
out[t] = {"type": "int", "flexible": True}
elif typ == "float":
out[t] = {"type": "float", "decimal_separator": "auto", "flexible": True}
else:
out[t] = {"type": "string"}
_apply_energy_kj_hint_from_headers(module, field_mappings, out)
return out

View File

@ -37,16 +37,43 @@ MODULE_DEFINITIONS: Dict[str, Dict[str, Any]] = {
"activity": {
"table": "activity_log",
"fields": {
"date": {"type": "date", "required": False},
"start_time": {"type": "datetime", "required": False},
"end_time": {"type": "datetime", "required": False},
"activity_type": {"type": "string", "required": True},
"duration_min": {"type": "float", "required": False, "min": 0},
"kcal_active": {"type": "float", "required": False, "unit": "kcal"},
"kcal_resting": {"type": "float", "required": False, "unit": "kcal"},
"distance_km": {"type": "float", "required": False, "unit": "km"},
"hr_avg": {"type": "float", "required": False, "min": 30, "max": 220},
"hr_max": {"type": "float", "required": False, "min": 30, "max": 220},
"date": {"type": "date", "required": False, "label_de": "Datum"},
"start_time": {
"type": "datetime",
"required": False,
"label_de": "Start (Datum/Uhrzeit)",
},
"end_time": {"type": "datetime", "required": False, "label_de": "Ende (Datum/Uhrzeit)"},
"activity_type": {"type": "string", "required": True, "label_de": "Trainingsart / Workout-Typ"},
"duration_min": {"type": "float", "required": False, "min": 0, "label_de": "Dauer (Minuten)"},
"kcal_active": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien aktiv"},
"kcal_resting": {"type": "float", "required": False, "unit": "kcal", "label_de": "Kalorien Ruhe"},
"distance_km": {"type": "float", "required": False, "unit": "km", "label_de": "Distanz (km)"},
"hr_avg": {
"type": "float",
"required": False,
"min": 30,
"max": 220,
"label_de": "Herzfrequenz Ø (bpm)",
},
"hr_max": {
"type": "float",
"required": False,
"min": 30,
"max": 220,
"label_de": "Herzfrequenz max (bpm)",
},
"hr_min": {"type": "int", "required": False, "label_de": "Herzfrequenz min (bpm)"},
"rpe": {"type": "int", "required": False, "label_de": "RPE (110)"},
"pace_min_per_km": {"type": "float", "required": False, "label_de": "Tempo (min/km)"},
"cadence": {"type": "int", "required": False, "label_de": "Kadenz"},
"avg_power": {"type": "int", "required": False, "label_de": "Leistung Ø (W)"},
"elevation_gain": {"type": "int", "required": False, "label_de": "Höhenmeter / Aufstieg"},
"temperature_celsius": {"type": "float", "required": False, "label_de": "Temperatur (°C)"},
"humidity_percent": {"type": "int", "required": False, "label_de": "Luftfeuchtigkeit (%)"},
"avg_hr_percent": {"type": "float", "required": False, "label_de": "HF Ø (% von max)"},
"kcal_per_km": {"type": "float", "required": False, "label_de": "Kalorien pro km"},
"notes": {"type": "string", "required": False, "label_de": "Notiz"},
},
"derive_date_from_datetime_field": "start_time",
"duplicate_key": ["profile_id", "date", "start_time"],
@ -125,13 +152,16 @@ def list_modules() -> list[str]:
return sorted(MODULE_DEFINITIONS.keys())
def validate_field_mappings(module: str, field_mappings: dict) -> None:
def validate_field_mappings(module: str, field_mappings: dict, cur=None) -> None:
"""Wirft ValueError bei unbekanntem Modul oder unbekanntem DB-Feld."""
mod = get_module_definition(module)
if not mod:
raise ValueError(f"Unbekanntes Modul: {module}")
fields = cast(dict, mod["fields"])
allowed = set(fields.keys())
if module == "activity" and cur is not None:
cur.execute("SELECT key FROM training_parameters WHERE is_active = true")
allowed.update(str(r["key"]) for r in cur.fetchall())
if not allowed:
for _csv_col, db_field in field_mappings.items():
if db_field not in ("", None, "-", "_skip"):

View File

@ -15,6 +15,7 @@ from csv_parser.module_registry import (
validate_field_mappings,
validate_required_field_targets,
)
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
ALLOWED_SPEC_TYPES = frozenset(
{"string", "float", "number", "int", "date", "time", "datetime", "duration"}
@ -50,6 +51,8 @@ def validate_csv_template(
type_conversions: Mapping[str, Any] | None = None,
import_row_processing: Mapping[str, Any] | None = None,
column_signature: list[str] | None = None,
*,
cur=None,
) -> dict[str, Any]:
"""
Prüft eine Vorlage ohne Datei-Upload.
@ -74,8 +77,12 @@ def validate_csv_template(
)
return {"valid": False, "errors": errors, "warnings": warnings}
field_defs = dict(mod.get("fields") or {})
if module == "activity" and cur is not None:
field_defs = merge_activity_csv_module_fields(cur, field_defs)
try:
validate_field_mappings(module, fm)
validate_field_mappings(module, fm, cur=cur)
except ValueError as e:
errors.append(
_issue(
@ -100,7 +107,7 @@ def validate_csv_template(
if import_row_processing:
try:
validate_import_row_processing_spec(module, import_row_processing, fm)
validate_import_row_processing_spec(module, import_row_processing, fm, cur=cur)
except ValueError as e:
errors.append(
_issue(
@ -111,7 +118,6 @@ def validate_csv_template(
)
)
field_defs = mod.get("fields") or {}
for db_field, spec in tc.items():
if db_field not in field_defs:
errors.append(

View File

@ -14,7 +14,7 @@ from typing import Any, Mapping, Sequence
from dateutil import parser as dateutil_parser
from csv_parser.core import normalize_header_for_signature
from csv_parser.core import canonical_csv_header_label, normalize_header_for_signature
from csv_parser.field_units import factor_source_to_canonical
# Alias → strptime (JSON in Kleinbuchstaben)
@ -477,7 +477,12 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
(wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
dann Abgleich aller Vorlagen-Keys über deren Normalform.
Zusätzlich: Präfix-Treffer für lange manuelle Keys (z. B. Apple
Aufgestiegene Höhe (m) ``aufgestiegene_höhe_(m)`` vs. Mapping
aufgestiegene Höhe ``aufgestiegene_höhe``) gewinnt der längste passende Key.
"""
csv_col = canonical_csv_header_label(csv_col)
v = field_mappings.get(csv_col)
if v:
return v if v not in ("-", "_skip") else None
@ -488,6 +493,27 @@ def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | N
for k, fv in field_mappings.items():
if normalize_header_for_signature(str(k)) == norm:
return fv if fv not in ("-", "_skip") else None
# Präfix-Match (min. Länge gegen false positives wie „datum“ → „datum_xyz“)
best_fv: str | None = None
best_nk_len = 0
min_prefix = 10
for k, fv in field_mappings.items():
if not fv or fv in ("-", "_skip"):
continue
nk = normalize_header_for_signature(str(k))
if len(nk) < min_prefix or len(nk) >= len(norm):
continue
if not norm.startswith(nk):
continue
boundary = norm[len(nk) : len(nk) + 1]
if boundary not in ("", "_", "("):
continue
if len(nk) > best_nk_len:
best_nk_len = len(nk)
best_fv = fv
if best_fv:
return best_fv
return None

View File

@ -22,6 +22,7 @@ from typing import Dict, List, Optional, Any
from datetime import datetime, timedelta, date, time
import statistics
from db import get_db, get_cursor, r2d
from data_layer.activity_session_metrics import enrich_sessions_with_metrics
from data_layer.utils import calculate_confidence, safe_float, safe_int, serialize_dates
@ -123,7 +124,8 @@ def get_activity_detail_data(
"duration_min": int,
"kcal_active": int,
"hr_avg": int | None,
"training_category": str | None
"training_category": str | None,
"session_metrics": list | None, # EAV (enrich_sessions_with_metrics)
},
...
],
@ -142,6 +144,7 @@ def get_activity_detail_data(
cur.execute(
"""SELECT
id,
date,
activity_type,
duration_min,
@ -152,7 +155,7 @@ def get_activity_detail_data(
WHERE profile_id=%s AND date >= %s
ORDER BY date DESC
LIMIT %s""",
(profile_id, cutoff, limit)
(profile_id, cutoff, limit),
)
rows = cur.fetchall()
@ -161,19 +164,24 @@ def get_activity_detail_data(
"activities": [],
"total_count": 0,
"confidence": "insufficient",
"days_analyzed": days
"days_analyzed": days,
}
activities = []
for row in rows:
activities.append({
"date": row['date'],
"activity_type": row['activity_type'],
"duration_min": safe_int(row['duration_min']),
"kcal_active": safe_int(row['kcal_active']),
"hr_avg": safe_int(row['hr_avg']) if row.get('hr_avg') else None,
"training_category": row.get('training_category')
})
activities.append(
{
"id": str(row["id"]),
"date": row["date"],
"activity_type": row["activity_type"],
"duration_min": safe_int(row["duration_min"]),
"kcal_active": safe_int(row["kcal_active"]),
"hr_avg": safe_int(row["hr_avg"]) if row.get("hr_avg") else None,
"training_category": row.get("training_category"),
}
)
enrich_sessions_with_metrics(cur, activities)
confidence = calculate_confidence(len(activities), days, "general")
@ -181,7 +189,7 @@ def get_activity_detail_data(
"activities": activities,
"total_count": len(activities),
"confidence": confidence,
"days_analyzed": days
"days_analyzed": days,
}
@ -1112,6 +1120,7 @@ def get_training_sessions_recent_weeks_data(
(profile_id, cutoff),
)
rows = [r2d(r) for r in cur.fetchall()]
enrich_sessions_with_metrics(cur, rows)
if not rows:
return {
@ -1141,6 +1150,7 @@ def get_training_sessions_recent_weeks_data(
hr_m = r.get("hr_max")
by_week[wk].append(
{
"id": str(r["id"]),
"date": d,
"start_time": str(r["start_time"]) if r.get("start_time") is not None else None,
"activity_type": r.get("activity_type"),
@ -1151,6 +1161,7 @@ def get_training_sessions_recent_weeks_data(
"hr_avg": int(hr_a) if hr_a is not None else None,
"hr_max": int(hr_m) if hr_m is not None else None,
"rpe": int(r["rpe"]) if r.get("rpe") is not None else None,
"session_metrics": r.get("session_metrics", []),
}
)

View File

@ -0,0 +1,410 @@
"""
Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, SpaltenEAV).
Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen.
Feld-Katalog für CSV-Mappings: get_mappable_activity_field_catalog()
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
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
logger = logging.getLogger(__name__)
try:
from evaluation_helper import evaluate_and_save_activity as _evaluate_and_save_activity
_EVALUATION_AVAILABLE = True
except Exception: # pragma: no cover
_evaluate_and_save_activity = None
_EVALUATION_AVAILABLE = False
def find_activity_duplicate_id(
cur,
profile_id: str,
date_iso: str,
start_time: Optional[Any],
) -> Optional[str]:
cur.execute(
"""
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s::date
AND start_time IS NOT DISTINCT FROM %s::time
""",
(profile_id, date_iso, start_time),
)
row = cur.fetchone()
return str(row["id"]) if row else None
# Datum/Start/Ende/Typ setzt der CSV-Executor explizit (Normalisierung); nicht aus diesem Patch überschreiben.
_ACTIVITY_CSV_REGISTRY_EXCLUDE = frozenset({"date", "start_time", "end_time", "activity_type"})
def activity_registry_field_keys() -> frozenset[str]:
mod = get_module_definition("activity")
if not mod:
return frozenset()
return frozenset((mod.get("fields") or {}).keys())
def activity_csv_registry_updates_from_mapped(mapped: Mapping[str, Any]) -> Dict[str, Any]:
"""
activity_log-Updates nur aus Modul-Registry-Feldern (Kernspalten).
Trainingsparameter-Keys (nur in training_parameters) laufen über EAV, nicht hier.
"""
mod = get_module_definition("activity")
if not mod:
return {}
fields = mod.get("fields") or {}
out: Dict[str, Any] = {}
def _sf(v: Any) -> float | None:
try:
if v is None or (isinstance(v, str) and not str(v).strip()):
return None
return round(float(v), 1)
except (TypeError, ValueError):
return None
def _si(v: Any) -> int | None:
try:
if v is None or (isinstance(v, str) and not str(v).strip()):
return None
return int(round(float(v)))
except (TypeError, ValueError):
return None
def _hr(v: Any) -> float | None:
x = _sf(v)
if x is None or x < 20 or x > 280:
return None
return x
for key, spec in fields.items():
if key in _ACTIVITY_CSV_REGISTRY_EXCLUDE:
continue
if key not in mapped:
continue
raw = mapped[key]
if raw is None or raw == "":
continue
if isinstance(raw, str) and not raw.strip():
continue
typ = spec.get("type", "string")
if typ == "float":
v = _hr(raw) if key in ("hr_avg", "hr_max") else _sf(raw)
if v is not None:
out[key] = v
elif typ == "int":
v = _si(raw)
if v is not None:
out[key] = v
elif typ == "datetime":
if isinstance(raw, dt.datetime):
out[key] = raw.strftime("%Y-%m-%d %H:%M:%S")
elif isinstance(raw, dt.date):
out[key] = f"{raw.isoformat()} 00:00:00"
elif isinstance(raw, str) and raw.strip():
out[key] = raw.strip()
elif typ == "date":
if isinstance(raw, dt.date):
out[key] = raw.isoformat()
elif isinstance(raw, dt.datetime):
out[key] = raw.date().isoformat()
elif isinstance(raw, str) and raw.strip():
out[key] = raw.strip()
else:
out[key] = str(raw).strip()
return out
def insert_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
"""INSERT activity_log aus ActivityEntry (manueller API-Pfad)."""
d = e.model_dump()
cur.execute(
"""INSERT INTO activity_log (id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,hr_min,distance_km,pace_min_per_km,cadence,avg_power,elevation_gain,
temperature_celsius,humidity_percent,avg_hr_percent,kcal_per_km,rpe,source,notes,
training_type_id,training_category,training_subcategory,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(
eid,
profile_id,
d["date"],
d["start_time"],
d["end_time"],
d["activity_type"],
d["duration_min"],
d["kcal_active"],
d["kcal_resting"],
d["hr_avg"],
d["hr_max"],
d.get("hr_min"),
d["distance_km"],
d.get("pace_min_per_km"),
d.get("cadence"),
d.get("avg_power"),
d.get("elevation_gain"),
d.get("temperature_celsius"),
d.get("humidity_percent"),
d.get("avg_hr_percent"),
d.get("kcal_per_km"),
d["rpe"],
d["source"],
d["notes"],
d.get("training_type_id"),
d.get("training_category"),
d.get("training_subcategory"),
),
)
def update_activity_from_entry(cur, profile_id: str, eid: str, e: ActivityEntry) -> None:
"""Volles UPDATE aus ActivityEntry (REST PUT)."""
d = e.model_dump()
cur.execute(
f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
list(d.values()) + [eid, profile_id],
)
def update_activity_columns(
cur,
profile_id: str,
eid: str,
updates: Dict[str, Any],
) -> None:
"""Teil-UPDATE nur für übergebene Spalten (Importe)."""
if not updates:
return
cols = [f"{k} = %s" for k in updates]
vals = list(updates.values()) + [eid, profile_id]
cur.execute(
f"UPDATE activity_log SET {', '.join(cols)} WHERE id = %s AND profile_id = %s",
vals,
)
def insert_activity_csv_minimal(
cur,
profile_id: str,
eid: str,
*,
date_iso: str,
start_time: Any,
end_time: Any,
activity_type: str,
duration_min: Any,
kcal_active: Any,
kcal_resting: Any,
hr_avg: Any,
hr_max: Any,
distance_km: Any,
training_type_id: Any,
training_category: Any,
training_subcategory: Any,
source: str,
) -> None:
"""INSERT minimale activity_log-Zeile (Universal-CSV)."""
cur.execute(
"""
INSERT INTO activity_log (
id, profile_id, date, start_time, end_time, activity_type, duration_min,
kcal_active, kcal_resting, hr_avg, hr_max, distance_km,
source, training_type_id, training_category, training_subcategory, created
)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)
""",
(
eid,
profile_id,
date_iso,
start_time,
end_time,
activity_type,
duration_min,
kcal_active,
kcal_resting,
hr_avg,
hr_max,
distance_km,
source,
training_type_id,
training_category,
training_subcategory,
),
)
def run_activity_post_write_hooks(cur, profile_id: str, eid: str) -> None:
"""Auto-Eval (falls aktiv) + EAV-Spiegel aus activity_log-Spalten."""
if _EVALUATION_AVAILABLE and _evaluate_and_save_activity:
cur.execute(
"""
SELECT id, profile_id, date, training_type_id, duration_min,
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
rpe, pace_min_per_km, cadence, elevation_gain
FROM activity_log
WHERE id = %s
""",
(eid,),
)
activity_row = cur.fetchone()
if activity_row:
activity_dict = dict(activity_row)
training_type_id = activity_dict.get("training_type_id")
if training_type_id:
try:
_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(
cur,
profile_id: str,
eid: str,
*,
workout_date: str,
training_type_id: Optional[int],
duration_min: Any,
hr_avg: Any,
hr_max: Any,
distance_km: Any,
kcal_active: Any,
kcal_resting: Any,
) -> None:
"""Eval + EAV nach Legacy-Import mit vorgebautem Kontext-Dict."""
if _EVALUATION_AVAILABLE and training_type_id and _evaluate_and_save_activity:
try:
activity_dict = {
"id": eid,
"profile_id": profile_id,
"date": workout_date,
"training_type_id": training_type_id,
"duration_min": duration_min,
"hr_avg": hr_avg,
"hr_max": hr_max,
"distance_km": distance_km,
"kcal_active": kcal_active,
"kcal_resting": kcal_resting,
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None,
}
_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(
cur,
static_fields: Dict[str, Any],
) -> Dict[str, Any]:
"""
activity-Modul für CSV: statische Registry-Felder + alle aktiven training_parameters.
Gleiche Quelle wie get_mappable_activity_field_catalog.training_parameters erscheint
in Admin-CSV-Ziel-Liste, Validierung und Import-Zeilenaggregation.
"""
out = dict(static_fields)
cur.execute(
"""
SELECT key, data_type, unit, name_de
FROM training_parameters
WHERE is_active = true
ORDER BY key
"""
)
for row in cur.fetchall():
k = row["key"]
if k in out:
continue
dt = row["data_type"] or "float"
if dt == "integer":
mtype = "int"
elif dt == "float":
mtype = "float"
elif dt == "boolean":
mtype = "string"
else:
mtype = "string"
spec: Dict[str, Any] = {
"type": mtype,
"required": False,
"from_training_parameter": True,
}
if row.get("unit"):
spec["unit"] = row["unit"]
if row.get("name_de"):
spec["label_de"] = row["name_de"]
out[k] = spec
return out
def get_mappable_activity_field_catalog(cur, profile_id: str) -> Dict[str, Any]:
"""
Felder für konfigurierbare Import-Mappings.
core_fields: module_registry activity activity_log.
training_parameters: alle aktiven Parameter (global); bei Anwendung auf eine Session
werden Keys verworfen, die nicht in resolve_activity_attribute_schema(Kategorie/Typ) liegen.
profile_id: reserviert für künftige Profil-Filter.
"""
_ = profile_id
mod = get_module_definition("activity") or {}
fields = mod.get("fields") or {}
core_fields: List[Dict[str, Any]] = []
for key, spec in fields.items():
s = spec or {}
core_fields.append(
{
"key": key,
"target": "activity_log",
"column": key,
"data_type": s.get("type", "string"),
"required": bool(s.get("required")),
"unit": s.get("unit"),
"label_de": s.get("label_de") or key,
}
)
core_fields.sort(key=lambda x: x["key"])
cur.execute(
"""
SELECT id, key, name_de, name_en, category AS param_category,
data_type, unit, source_field
FROM training_parameters
WHERE is_active = true
ORDER BY key
"""
)
parameters = [dict(r) for r in cur.fetchall()]
return {
"core_fields": core_fields,
"training_parameters": parameters,
"notes": (
"training_parameters listet alle aktiven Keys. Pro Session werden Werte ignoriert, "
"die für deren training_category/training_type_id nicht im Attribut-Schema vorkommen."
),
}
def new_activity_id() -> str:
return str(uuid.uuid4())

View File

@ -0,0 +1,610 @@
"""
Activity session metrics (EAV) and resolved attribute schema Layer 1.
See: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, List, Mapping, Optional, Sequence
from csv_parser.module_registry import get_module_definition
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(
{
"id",
"profile_id",
"date",
"created",
"training_type_id",
"training_category",
"training_subcategory",
"source",
}
)
class ActivitySessionMetricsError(Exception):
"""Raised by Layer 1; routers map to HTTP (404/400)."""
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
super().__init__(detail)
def _effective_training_category(
cur, training_category: Optional[str], training_type_id: Optional[int]
) -> Optional[str]:
if training_category:
return training_category.strip() or None
if training_type_id is None:
return None
cur.execute("SELECT category FROM training_types WHERE id = %s", (training_type_id,))
row = cur.fetchone()
if row and row.get("category"):
return row["category"]
return None
def merge_parameter_schema_rows(
category_rows: Sequence[Dict[str, Any]],
type_rows: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Pure merge: category assignments + type assignments sorted schema list.
Row shapes match SELECTs in resolve_activity_attribute_schema (cat_sort / typ_* aliases).
"""
merged: Dict[int, Dict[str, Any]] = {}
for r in category_rows:
pid = r["training_parameter_id"]
merged[pid] = {
"training_parameter_id": pid,
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
"validation_rules": r["validation_rules"] or {},
"source_field": r["source_field"],
"sort_order": r["cat_sort"],
"required": bool(r["cat_required"]),
"ui_group": r["cat_ui_group"],
}
for r in type_rows:
pid = r["training_parameter_id"]
base = merged.get(pid)
if base is None:
merged[pid] = {
"training_parameter_id": pid,
"key": r["key"],
"name_de": r["name_de"],
"name_en": r["name_en"],
"param_category": r["param_category"],
"data_type": r["data_type"],
"unit": r["unit"],
"validation_rules": r["validation_rules"] or {},
"source_field": r["source_field"],
"sort_order": r["typ_sort"] if r["typ_sort"] is not None else 0,
"required": bool(r["typ_required"]) if r["typ_required"] is not None else False,
"ui_group": r["typ_ui_group"],
}
else:
if r["typ_sort"] is not None:
base["sort_order"] = r["typ_sort"]
if r["typ_required"] is not None:
base["required"] = bool(r["typ_required"])
if r["typ_ui_group"] is not None:
base["ui_group"] = r["typ_ui_group"]
out = list(merged.values())
out.sort(key=lambda x: (x["sort_order"], x["key"]))
return out
def resolve_activity_attribute_schema(
cur,
training_category: Optional[str],
training_type_id: Optional[int],
) -> List[Dict[str, Any]]:
"""
Merged parameter definitions for UI / validation (category base + type overrides/additions).
Sorted by sort_order, then key.
"""
cat = _effective_training_category(cur, training_category, training_type_id)
category_rows: List[Dict[str, Any]] = []
type_rows: List[Dict[str, Any]] = []
if cat:
cur.execute(
"""
SELECT
tcp.training_parameter_id,
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.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
WHERE tcp.training_category = %s AND tp.is_active = true
""",
(cat,),
)
category_rows = list(cur.fetchall())
if training_type_id is not None:
cur.execute(
"""
SELECT
ttp.training_parameter_id,
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.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
WHERE ttp.training_type_id = %s AND tp.is_active = true
""",
(training_type_id,),
)
type_rows = list(cur.fetchall())
return merge_parameter_schema_rows(category_rows, type_rows)
def _validation_rules_dict(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
return {}
def _validate_single_value(data_type: str, value: Any, rules: Dict[str, Any]) -> None:
if data_type == "integer":
if not isinstance(value, int) or isinstance(value, bool):
raise ActivitySessionMetricsError(400, f"Erwartet integer, erhalten: {type(value).__name__}")
if "min" in rules and value < rules["min"]:
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
if "max" in rules and value > rules["max"]:
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
elif data_type == "float":
if isinstance(value, bool) or not isinstance(value, (int, float, Decimal)):
raise ActivitySessionMetricsError(400, f"Erwartet Zahl, erhalten: {type(value).__name__}")
v = float(value)
if "min" in rules and v < float(rules["min"]):
raise ActivitySessionMetricsError(400, f"Wert unter min ({rules['min']})")
if "max" in rules and v > float(rules["max"]):
raise ActivitySessionMetricsError(400, f"Wert über max ({rules['max']})")
elif data_type == "string":
if not isinstance(value, str):
raise ActivitySessionMetricsError(400, f"Erwartet string, erhalten: {type(value).__name__}")
if rules.get("not_empty") and not value.strip():
raise ActivitySessionMetricsError(400, "Leerer String nicht erlaubt")
if "max_length" in rules and len(value) > int(rules["max_length"]):
raise ActivitySessionMetricsError(400, f"String zu lang (max {rules['max_length']})")
allowed = rules.get("allowed_values")
if allowed and value not in allowed:
raise ActivitySessionMetricsError(400, "Wert nicht in erlaubter Menge")
elif data_type == "boolean":
if not isinstance(value, bool):
raise ActivitySessionMetricsError(400, f"Erwartet boolean, erhalten: {type(value).__name__}")
else:
raise ActivitySessionMetricsError(400, f"Unbekannter data_type: {data_type}")
def _row_value_tuple(data_type: str, value: Any) -> tuple:
if data_type == "integer":
return (None, int(value), None, None)
if data_type == "float":
return (float(value), None, None, None)
if data_type == "string":
return (None, None, str(value), None)
if data_type == "boolean":
return (None, None, None, bool(value))
raise ValueError(data_type)
def _coerce_raw_value_for_parameter(data_type: str, raw: Any) -> Any:
"""Wert aus activity_log-Spalte in den Typ bringen, den training_parameters.data_type erwartet."""
if data_type == "integer":
if isinstance(raw, bool):
raise TypeError("boolean nicht als integer erlaubt")
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return int(round(float(s)))
return int(round(float(raw)))
if data_type == "float":
if isinstance(raw, str):
s = raw.strip().replace(",", ".")
return float(s)
return float(raw)
if data_type == "string":
return str(raw) if raw is not None else ""
if data_type == "boolean":
if isinstance(raw, bool):
return raw
s = str(raw).strip().lower()
if s in ("true", "1", "t", "yes"):
return True
if s in ("false", "0", "f", "no", ""):
return False
raise TypeError(f"boolean-Koercion nicht möglich: {raw!r}")
raise ValueError(data_type)
def upsert_session_metrics_from_csv_mapped(
cur,
profile_id: str,
activity_log_id: str,
mapped: Mapping[str, Any],
training_category: Optional[str],
training_type_id: Optional[int],
) -> None:
"""
EAV für Trainingsparameter aus CSV (nur Keys, die nicht im activity-Modul-Registry liegen).
Kernfelder (Datum, Start, Distanz, HF, ) schreibt der Executor nach activity_log;
hier keine doppelten EAV-Zeilen für dieselben Registry-Keys.
"""
cur.execute(
"SELECT profile_id 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())
schema = resolve_activity_attribute_schema(cur, training_category, training_type_id)
for spec in schema:
pkey = spec["key"]
if pkey not in mapped:
continue
raw = mapped[pkey]
if raw is None or raw == "":
continue
if pkey in activity_registry_keys:
continue
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
try:
coerced = _coerce_raw_value_for_parameter(dt, raw)
_validate_single_value(dt, coerced, rules)
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
logger.warning("CSV EAV skipped %s: %s", pkey, ex)
continue
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (activity_log_id, training_parameter_id)
DO UPDATE SET
value_num = EXCLUDED.value_num,
value_int = EXCLUDED.value_int,
value_text = EXCLUDED.value_text,
value_bool = EXCLUDED.value_bool,
updated_at = NOW()
""",
(activity_log_id, tid, vn, vi, vt, vb),
)
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.
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.
"""
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
header = dict(row)
schema = resolve_activity_attribute_schema(
cur, header.get("training_category"), header.get("training_type_id")
)
for spec in schema:
sf = spec.get("source_field")
if sf is None or (isinstance(sf, str) and not str(sf).strip()):
continue
col = str(sf).strip()
if col not in header:
continue
raw = header[col]
tid = spec["training_parameter_id"]
dt = spec["data_type"]
rules = _validation_rules_dict(spec["validation_rules"])
if raw is None:
cur.execute(
"""
DELETE FROM activity_session_metrics
WHERE activity_log_id = %s AND training_parameter_id = %s
""",
(activity_log_id, tid),
)
continue
try:
coerced = _coerce_raw_value_for_parameter(dt, raw)
_validate_single_value(dt, coerced, rules)
except (ActivitySessionMetricsError, TypeError, ValueError) as ex:
logger.warning(
"sync_column_backed_session_metrics: überspringe %s (Spalte %s): %s",
spec.get("key"),
col,
ex,
)
continue
vn, vi, vt, vb = _row_value_tuple(dt, coerced)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (activity_log_id, training_parameter_id)
DO UPDATE SET
value_num = EXCLUDED.value_num,
value_int = EXCLUDED.value_int,
value_text = EXCLUDED.value_text,
value_bool = EXCLUDED.value_bool,
updated_at = NOW()
""",
(activity_log_id, tid, vn, vi, vt, vb),
)
def fetch_activity_session_metrics(cur, activity_log_id: str) -> List[Dict[str, Any]]:
cur.execute(
"""
SELECT
m.id,
m.activity_log_id,
m.training_parameter_id,
m.value_num,
m.value_int,
m.value_text,
m.value_bool,
tp.key,
tp.data_type,
tp.unit
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id = %s
ORDER BY tp.key
""",
(activity_log_id,),
)
rows = cur.fetchall()
out: List[Dict[str, Any]] = []
for r in rows:
dt = r["data_type"]
if dt == "integer":
val = int(r["value_int"]) if r["value_int"] is not None else None
elif dt == "float":
val = float(r["value_num"]) if r["value_num"] is not None else None
elif dt == "string":
val = r["value_text"]
else:
val = r["value_bool"]
out.append(
{
"training_parameter_id": r["training_parameter_id"],
"key": r["key"],
"data_type": dt,
"unit": r["unit"],
"value": val,
}
)
return out
def replace_activity_session_metrics(
cur,
profile_id: str,
activity_log_id: str,
metrics: Sequence[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""
Full replace of EAV rows for this session. metrics: [{ "parameter_key": str, "value": ... }, ...]
"""
cur.execute(
"""
SELECT id, profile_id, training_category, training_type_id
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")
schema = resolve_activity_attribute_schema(
cur, row.get("training_category"), row.get("training_type_id")
)
by_key = {s["key"]: s for s in schema}
payload_by_key: Dict[str, Dict[str, Any]] = {}
for item in metrics:
raw_k = item.get("parameter_key")
if raw_k is None or not str(raw_k).strip():
raise ActivitySessionMetricsError(400, "parameter_key fehlt")
k = str(raw_k).strip()
if k not in by_key:
raise ActivitySessionMetricsError(400, f"Unbekannter oder nicht zugewiesener Parameter: {k}")
payload_by_key[k] = item
for s in schema:
if not s["required"]:
continue
itk = s["key"]
hit = payload_by_key.get(itk)
if hit is None or hit.get("value") is None:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {itk}")
cur.execute(
"DELETE FROM activity_session_metrics WHERE activity_log_id = %s",
(activity_log_id,),
)
for item in metrics:
k = str(item["parameter_key"]).strip()
spec = by_key[k]
val = item.get("value")
if val is None:
if spec["required"]:
raise ActivitySessionMetricsError(400, f"Pflichtfeld fehlt: {k}")
continue
rules = _validation_rules_dict(spec["validation_rules"])
_validate_single_value(spec["data_type"], val, rules)
vn, vi, vt, vb = _row_value_tuple(spec["data_type"], val)
cur.execute(
"""
INSERT INTO activity_session_metrics (
activity_log_id, training_parameter_id,
value_num, value_int, value_text, value_bool, updated_at
) VALUES (%s, %s, %s, %s, %s, %s, NOW())
""",
(activity_log_id, spec["training_parameter_id"], vn, vi, vt, vb),
)
# Kein sync_column_backed nach PUT /metrics: der Request ist maßgeblich für EAV. Ein Spalten-Sync würde
# Werte aus nicht mitgeschriebenen activity_log-Spalten wieder verwerfen.
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]:
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")
)
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"])
return {
"header": header,
"schema": schema,
"metrics": merged_metrics,
}
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."""
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
m.activity_log_id,
tp.key,
tp.data_type,
tp.unit,
m.value_num,
m.value_int,
m.value_text,
m.value_bool
FROM activity_session_metrics m
JOIN training_parameters tp ON tp.id = m.training_parameter_id
WHERE m.activity_log_id IN ({ph})
ORDER BY m.activity_log_id, tp.key
""",
ids,
)
by_act: Dict[str, List[Dict[str, Any]]] = {}
for r in cur.fetchall():
aid = str(r["activity_log_id"])
dt = r["data_type"]
if dt == "integer":
val = int(r["value_int"]) if r["value_int"] is not None else None
elif dt == "float":
val = float(r["value_num"]) if r["value_num"] is not None else None
elif dt == "string":
val = r["value_text"]
else:
val = r["value_bool"]
by_act.setdefault(aid, []).append(
{"key": r["key"], "data_type": dt, "unit": r["unit"], "value": val}
)
for s in sessions:
aid = str(s.get("id"))
s["session_metrics"] = by_act.get(aid, [])

View File

@ -0,0 +1,30 @@
"""
Einheitliche Startzeit-Normalisierung für Aktivität (CSV, Legacy-Import, Dedupe).
Anbieter-agnostisch: beliebige ISO-/Export-Strings über dateutil.
"""
from __future__ import annotations
from datetime import time as dt_time
from typing import Optional
from dateutil import parser as du_parser
def normalize_activity_start(start_raw: str) -> tuple[str, Optional[dt_time]]:
"""
Roh-String Start aus Exporten (YYYY-MM-DD, TIME ohne μs) für DB Dedupe/INSERT.
Leerer Input ("", None). Fallback bei Parse-Fehler: erstes Datum aus ersten 10 Zeichen.
"""
s = (start_raw or "").strip()
if not s:
return "", None
try:
parsed = du_parser.parse(s, dayfirst=False)
t = parsed.time().replace(microsecond=0)
return parsed.date().isoformat(), t
except (ValueError, TypeError, OverflowError):
if len(s) >= 10:
return s[:10], None
return "", None

View File

@ -29,7 +29,9 @@ def init_pool():
user=os.getenv("DB_USER", "mitai"),
password=os.getenv("DB_PASSWORD", "")
)
print(f"✓ PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})")
print(
f"[OK] PostgreSQL connection pool initialized ({os.getenv('DB_HOST', 'postgres')}:{os.getenv('DB_PORT', '5432')})"
)
@contextmanager
@ -171,7 +173,7 @@ def init_db():
) as table_exists
""")
if not cur.fetchone()['table_exists']:
print("⚠️ ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
print("[WARN] ai_prompts table doesn't exist yet - skipping pipeline prompt creation")
return
# Ensure "pipeline" master prompt exists
@ -189,7 +191,7 @@ def init_db():
)
""")
conn.commit()
print(" Pipeline master prompt created")
print("[OK] Pipeline master prompt created")
except Exception as e:
print(f"⚠️ Could not create pipeline prompt: {e}")
print(f"[WARN] Could not create pipeline prompt: {e}")
# Don't fail startup - prompt can be created manually

View File

@ -36,6 +36,7 @@ from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
# ── App Configuration ─────────────────────────────────────────────────────────
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
@ -66,7 +67,7 @@ async def startup_event():
try:
init_db()
except Exception as e:
print(f"⚠️ init_db() failed (non-fatal): {e}")
print(f"[WARN] init_db() failed (non-fatal): {e}")
# Don't crash on startup - can be created manually
# Apply v9c migration if needed
@ -74,7 +75,7 @@ async def startup_event():
from apply_v9c_migration import apply_migration
apply_migration()
except Exception as e:
print(f"⚠️ v9c migration failed (non-fatal): {e}")
print(f"[WARN] v9c migration failed (non-fatal): {e}")
# ── Register Routers ──────────────────────────────────────────────────────────
app.include_router(auth.router) # /api/auth/*
@ -128,6 +129,8 @@ app.include_router(admin_reference_value_types.router) # /api/admin/reference-v
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
app.include_router(admin_activity_attribute_profiles.router) # /api/admin/training-*-parameters
# ── Health Check ──────────────────────────────────────────────────────────────
@app.get("/")

View File

@ -0,0 +1,80 @@
-- Migration 054: Activity session metrics (EAV) + attribute profiles
-- Date: 2026-04-14
-- Additive only: safe for production (no data deletion).
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
-- Session interval (nullable; optional backfill later)
ALTER TABLE activity_log
ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_activity_log_profile_started
ON activity_log (profile_id, started_at DESC)
WHERE started_at IS NOT NULL;
COMMENT ON COLUMN activity_log.started_at IS 'Training start (wall clock, TZ-aware); optional; for dedupe/analysis';
COMMENT ON COLUMN activity_log.ended_at IS 'Training end (wall clock, TZ-aware); optional';
-- Which parameters apply to which training category (training_types.category)
CREATE TABLE IF NOT EXISTS training_category_parameter (
id SERIAL PRIMARY KEY,
training_category VARCHAR(50) NOT NULL,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT false,
ui_group VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_training_category_parameter UNIQUE (training_category, training_parameter_id)
);
CREATE INDEX IF NOT EXISTS idx_tcp_category ON training_category_parameter (training_category);
COMMENT ON TABLE training_category_parameter IS 'EAV schema: parameters enabled per training category';
-- Per training type: extra parameters or overrides (NULL sort/required/ui = inherit from category row if present)
CREATE TABLE IF NOT EXISTS training_type_parameter (
id SERIAL PRIMARY KEY,
training_type_id INT NOT NULL REFERENCES training_types(id) ON DELETE CASCADE,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE CASCADE,
sort_order INT,
required BOOLEAN,
ui_group VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_training_type_parameter UNIQUE (training_type_id, training_parameter_id)
);
CREATE INDEX IF NOT EXISTS idx_ttp_type ON training_type_parameter (training_type_id);
COMMENT ON TABLE training_type_parameter IS 'EAV schema: add/override parameters for a concrete training_types row';
-- EAV values per activity session
CREATE TABLE IF NOT EXISTS activity_session_metrics (
id BIGSERIAL PRIMARY KEY,
activity_log_id UUID NOT NULL REFERENCES activity_log(id) ON DELETE CASCADE,
training_parameter_id INT NOT NULL REFERENCES training_parameters(id) ON DELETE RESTRICT,
value_num DOUBLE PRECISION,
value_int BIGINT,
value_text TEXT,
value_bool BOOLEAN,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uq_activity_session_metric UNIQUE (activity_log_id, training_parameter_id),
CONSTRAINT chk_activity_session_metric_one_value CHECK (
(
(value_num IS NOT NULL)::int
+ (value_int IS NOT NULL)::int
+ (value_text IS NOT NULL)::int
+ (value_bool IS NOT NULL)::int
) = 1
)
);
CREATE INDEX IF NOT EXISTS idx_asm_activity ON activity_session_metrics (activity_log_id);
CREATE INDEX IF NOT EXISTS idx_asm_parameter ON activity_session_metrics (training_parameter_id);
COMMENT ON TABLE activity_session_metrics IS 'EAV: one row per (session, training_parameter); exactly one value_* set';
DO $$
BEGIN
RAISE NOTICE 'Migration 054: activity_session_metrics EAV + attribute profile tables + activity_log timestamps';
END $$;

View File

@ -0,0 +1,213 @@
-- Migration 055: Seed training_category_parameter (all categories × parameters with activity_log source_field)
-- + idempotent backfill activity_log → activity_session_metrics (EAV)
-- Date: 2026-04-15
-- SAFE: INSERT … ON CONFLICT DO NOTHING only; no DELETE/TRUNCATE on activity_log.
-- Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
--1) Jede in training_types vorkommende Kategorie erhält alle aktiven Parameter mit source_field (Spalte in activity_log).
INSERT INTO training_category_parameter (
training_category,
training_parameter_id,
sort_order,
required,
ui_group
)
SELECT
tc.training_category,
tp.id,
ROW_NUMBER() OVER (
PARTITION BY tc.training_category
ORDER BY tp.category, tp.id
),
false,
NULL
FROM (
SELECT DISTINCT category AS training_category
FROM training_types
WHERE category IS NOT NULL AND trim(category) <> ''
) tc
CROSS JOIN training_parameters tp
WHERE tp.is_active = true
AND tp.source_field IS NOT NULL
AND trim(tp.source_field) <> ''
ON CONFLICT (training_category, training_parameter_id) DO NOTHING;
-- 2) Backfill: activity_log-Spalten → EAV (nur wenn noch keine Zeile existiert)
-- duration_min → integer
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,
ROUND(a.duration_min::numeric)::bigint,
NULL,
NULL,
NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'duration_min' AND tp.is_active = true
WHERE a.duration_min IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- distance_km → float
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.distance_km::double precision, NULL, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'distance_km' AND tp.is_active = true
WHERE a.distance_km IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- kcal_active
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, ROUND(a.kcal_active::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_active' AND tp.is_active = true
WHERE a.kcal_active 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, ROUND(a.kcal_resting::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'kcal_resting' AND tp.is_active = true
WHERE a.kcal_resting IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- hr_avg / hr_max → keys avg_hr, max_hr
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, ROUND(a.hr_avg::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'avg_hr' AND tp.is_active = true
WHERE a.hr_avg 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, ROUND(a.hr_max::numeric)::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'max_hr' AND tp.is_active = true
WHERE a.hr_max IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- rpe
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.rpe::bigint, NULL, NULL, NOW()
FROM activity_log a
JOIN training_parameters tp ON tp.key = 'rpe' AND tp.is_active = true
WHERE a.rpe IS NOT NULL
ON CONFLICT (activity_log_id, training_parameter_id) DO NOTHING;
-- min_hr (Spalte hr_min nach Migration 014)
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;
DO $$
BEGIN
RAISE NOTICE 'Migration 055: category parameter seed + EAV backfill from activity_log (no row deletes)';
END $$;

View File

@ -0,0 +1,35 @@
-- Migration 056: kcal_per_km Trigger — manuelles Leeren bei UPDATE erlauben
-- Problem: calculate_avg_hr_percent (014) setzte bei jedem UPDATE kcal_per_km aus
-- kcal_active/distance_km, sobald beide gesetzt waren — ein bewusst geleertes Feld
-- erschien sofort wieder.
-- Lösung: automatische Ableitung nur noch bei INSERT (wenn kcal_per_km noch NULL ist).
CREATE OR REPLACE FUNCTION calculate_avg_hr_percent()
RETURNS TRIGGER AS $$
DECLARE
user_max_hr INTEGER;
BEGIN
SELECT hf_max INTO user_max_hr
FROM profiles
WHERE id = NEW.profile_id;
IF NEW.hr_avg IS NOT NULL AND user_max_hr IS NOT NULL AND user_max_hr > 0 THEN
NEW.avg_hr_percent := (NEW.hr_avg::float / user_max_hr::float) * 100;
END IF;
IF TG_OP = 'INSERT' THEN
IF NEW.kcal_active IS NOT NULL AND NEW.distance_km IS NOT NULL AND NEW.distance_km > 0 THEN
IF NEW.kcal_per_km IS NULL THEN
NEW.kcal_per_km := NEW.kcal_active::float / NEW.distance_km;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
RAISE NOTICE '✓ Migration 056: kcal_per_km nur noch bei INSERT auto-abgeleitet';
END $$;

View File

@ -3,8 +3,9 @@ Pydantic Models for Mitai Jinkendo API
Data validation schemas for request/response bodies.
"""
from typing import Optional
from pydantic import BaseModel
from typing import Any, List, Optional
from pydantic import BaseModel, Field
# ── Profile Models ────────────────────────────────────────────────────────────
@ -82,8 +83,17 @@ class ActivityEntry(BaseModel):
kcal_resting: Optional[float] = None
hr_avg: Optional[float] = None
hr_max: Optional[float] = None
hr_min: Optional[int] = None # DB-Spalte hr_min (Parameter min_hr)
distance_km: Optional[float] = None
rpe: Optional[int] = None
pace_min_per_km: Optional[float] = None
cadence: Optional[int] = None
avg_power: Optional[int] = None
elevation_gain: Optional[int] = None
temperature_celsius: Optional[float] = None
humidity_percent: Optional[int] = None
avg_hr_percent: Optional[float] = None
kcal_per_km: Optional[float] = None
source: Optional[str] = 'manual'
notes: Optional[str] = None
training_type_id: Optional[int] = None # v9d: Training type categorization
@ -91,6 +101,17 @@ class ActivityEntry(BaseModel):
training_subcategory: Optional[str] = None # v9d: Denormalized subcategory
class ActivityMetricValue(BaseModel):
parameter_key: str
value: Any
class ActivityMetricsReplace(BaseModel):
"""Voller Ersatz der EAV-Metriken für eine Session (siehe Agent-Guide)."""
metrics: List[ActivityMetricValue] = Field(default_factory=list)
class NutritionDay(BaseModel):
date: str
kcal: Optional[float] = None

View File

@ -127,16 +127,17 @@ def register_activity_group_1():
activity_detail_metadata = PlaceholderMetadata(
key="activity_detail",
category="Aktivität",
description="Detaillierte Liste der letzten 14 Tage Aktivität",
description="Detaillierte Liste der letzten 14 Tage Aktivität (Kopfzeile + EAV-Metriken)",
resolver_module="backend/placeholder_resolver.py",
resolver_function="_format_activity_detail",
data_layer_module=None,
data_layer_function=None,
source_tables=["activity_log", "training_types"],
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 eine strukturierte Liste aller Trainingseinheiten der letzten 14 Tage. "
"Jede Einheit: Datum, Trainingstyp, Dauer (Minuten), optional Notizen. "
"Sortiert chronologisch absteigend (neueste zuerst)."
"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."
),
business_meaning=(
"Detaillierte Trainingshistorie für KI-Prompts, die Muster, Progressionen "
@ -147,7 +148,9 @@ def register_activity_group_1():
time_window="14d",
output_type=OutputType.LIST,
placeholder_type=PlaceholderType.RAW_DATA,
format_hint="Liste von Strings, eine Zeile pro Einheit: 'YYYY-MM-DD: Typ (Dauer min)'",
format_hint=(
"Pro Zeile: Datum, Typ, Dauer, kcal, optional HF, optional „| EAV: …“ aus Session-Metriken"
),
example_output=(
"2026-03-28: Krafttraining (45 min)\\n"
"2026-03-27: Laufen (30 min)\\n"
@ -163,19 +166,15 @@ def register_activity_group_1():
legacy_display="Keine Aktivitätsdaten"
),
known_limitations=(
"OLD RESOLVER PATTERN: Keine Data Layer Funktion. "
"Formatierung direkt im Resolver. "
"CRITICAL: Keine Qualitätsfilterung - auch ungültige Einheiten (z.B. 0 min) "
"werden gelistet. JOIN mit training_types für Typ-Namen."
"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."
),
layer_1_decision="NONE - Old resolver pattern (direct SQL in resolver)",
layer_2a_decision="Placeholder Resolver (formatting + SQL query)",
layer_2b_reuse_possible=False,
architecture_alignment=(
"NOT ALIGNED with Phase 0c Multi-Layer Architecture. "
"Should be refactored to use data_layer function."
),
issue_53_alignment="NOT ALIGNED - no layer separation"
layer_1_decision="activity_metrics.get_activity_detail_data (+ enrich_sessions_with_metrics)",
layer_2a_decision="get_activity_detail (Formatierung)",
layer_2b_reuse_possible=True,
architecture_alignment="Phase 0c Layer 1 + EAV-Anreicherung",
issue_53_alignment="Layer 1"
)
activity_detail_metadata.set_evidence("key", EvidenceType.CODE_DERIVED)

View File

@ -130,15 +130,17 @@ 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)"
"JSON: letzte ISO-Kalenderwochen mit Einheiten (Datum, Art, Dauer, kcal, HF Ø/max, RPE, Kategorie, "
"session_id, session_metrics[] aus EAV)"
),
resolver_module="backend/placeholder_resolver.py",
resolver_function="_safe_json",
data_layer_module="backend/data_layer/activity_metrics.py",
data_layer_function="get_training_sessions_recent_weeks_data",
source_tables=["activity_log", "training_types"],
source_tables=["activity_log", "training_types", "activity_session_metrics", "training_parameters"],
semantic_contract=(
"Struktur weeks[].week_iso, sessions[] mit Feldern für KI-Auswertung. "
"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."
),
business_meaning="Rohkontext für wochenweise Auswertung (Erholung, Intensität) in der KI",
@ -158,7 +160,8 @@ 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."
"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."
),
layer_1_decision="activity_metrics.get_training_sessions_recent_weeks_data",
layer_2a_decision="_safe_json('training_sessions_recent_json')",

View File

@ -419,14 +419,21 @@ def get_activity_detail(profile_id: str, days: int = 14) -> str:
# Format as readable list (max 20 entries to avoid token bloat)
lines = []
for activity in data['activities'][:20]:
hr_str = f" HF={activity['hr_avg']}" if activity['hr_avg'] else ""
for activity in data["activities"][:20]:
hr_str = f", HF={activity['hr_avg']}" if activity.get("hr_avg") else ""
eav_parts = []
for m in activity.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}")
eav_str = f" | EAV: {'; '.join(eav_parts)}" if eav_parts else ""
lines.append(
f"{activity['date']}: {activity['activity_type']} "
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str})"
f"({activity['duration_min']}min, {activity['kcal_active']}kcal{hr_str}{eav_str})"
)
return '\n'.join(lines)
return "\n".join(lines)
def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str:

View File

@ -7,16 +7,69 @@ import csv
import io
import uuid
import logging
import re
import calendar
from datetime import date
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage
from models import ActivityEntry
from models import ActivityEntry, ActivityMetricsReplace
from routers.profiles import get_pid
from feature_logger import log_feature_usage
from quality_filter import get_quality_filter_sql
from data_layer.activity_persistence_orchestrator import (
get_mappable_activity_field_catalog,
insert_activity_from_entry,
run_activity_post_write_hooks,
update_activity_from_entry,
find_activity_duplicate_id,
update_activity_columns,
insert_activity_csv_minimal,
run_activity_post_write_hooks_import,
new_activity_id,
)
from data_layer.activity_time_normalize import normalize_activity_start
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
def _month_date_bounds(ym: str) -> tuple[date, date]:
m = _MONTH_RE.match((ym or "").strip())
if not m:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
y, mo = int(m.group(1)), int(m.group(2))
if mo < 1 or mo > 12:
raise HTTPException(status_code=400, detail="Ungültiger Monat")
last = calendar.monthrange(y, mo)[1]
return date(y, mo, 1), date(y, mo, last)
_ACTIVITY_DEDUP_WINDOW = """
PARTITION BY al.profile_id, al.date,
COALESCE(al.activity_type, ''),
COALESCE(al.start_time::text, ''),
COALESCE(ROUND(al.duration_min::numeric, 1), '-999999'::numeric),
COALESCE(ROUND(al.kcal_active::numeric, 1), '-999999'::numeric)
ORDER BY al.created DESC NULLS LAST, al.id DESC
"""
def _activity_rows_after_list_query(cur):
rows = []
for r in cur.fetchall():
d = r2d(r)
if not d:
continue
d.pop("_dup_rn", None)
rows.append(d)
return rows
# Evaluation import with error handling (Phase 1.2)
try:
@ -27,49 +80,139 @@ except Exception as e:
EVALUATION_AVAILABLE = False
evaluate_and_save_activity = None
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
@router.get("")
def list_activity(
limit: int = Query(200, ge=1, le=50_000),
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"),
x_profile_id: Optional[str] = Header(default=None),
month: Optional[str] = Query(
None,
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
),
skip_quality_filter: bool = Query(
False,
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
),
collapse_duplicate_sessions: bool = Query(
False,
description="True = Sessions mit gleichem Datum/Typ/Startzeit/Dauer/Kcal falten (neueste Zeile behalten).",
),
session: dict = Depends(require_auth),
):
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
pid = get_pid(x_profile_id)
# Immer das Profil der gültigen Session (X-Profile-Id wird hier nicht verwendet).
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
# Issue #31: Apply global quality filter (profile from DB = saved level)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile)
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
if skip_quality_filter:
quality_filter = ""
quality_filter_al = ""
else:
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {}, "")
quality_filter_al = get_quality_filter_sql(profile or {}, "al.")
if month:
if days is not None:
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
if offset != 0:
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
d0, d1 = _month_date_bounds(month)
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
AND al.date >= %s AND al.date <= %s
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return _activity_rows_after_list_query(cur)
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= %s AND date <= %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return [r2d(r) for r in cur.fetchall()]
if days is not None:
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
AND al.date >= (CURRENT_DATE - %s::integer)
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s OFFSET %s
""",
(pid, days, limit, offset),
)
return _activity_rows_after_list_query(cur)
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= (CURRENT_DATE - %s::integer)
ORDER BY date DESC, start_time DESC
LIMIT %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s OFFSET %s
""",
(pid, days, limit),
(pid, days, limit, offset),
)
else:
if collapse_duplicate_sessions:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
{quality_filter_al}
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT %s OFFSET %s
""",
(pid, limit, offset),
)
return _activity_rows_after_list_query(cur)
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC, start_time DESC
LIMIT %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s OFFSET %s
""",
(pid, limit),
(pid, limit, offset),
)
return [r2d(r) for r in cur.fetchall()]
@ -95,37 +238,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
)
eid = str(uuid.uuid4())
d = e.model_dump()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""INSERT INTO activity_log
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,distance_km,rpe,source,notes,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""",
(eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'],
d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'],
d['rpe'],d['source'],d['notes']))
# Phase 1.2: Auto-evaluation after INSERT
if EVALUATION_AVAILABLE:
# Load the activity data to evaluate
cur.execute("""
SELECT id, profile_id, date, training_type_id, duration_min,
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
rpe, pace_min_per_km, cadence, elevation_gain
FROM activity_log
WHERE id = %s
""", (eid,))
activity_row = cur.fetchone()
if activity_row:
activity_dict = dict(activity_row)
training_type_id = activity_dict.get("training_type_id")
if training_type_id:
try:
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
logger.info(f"[AUTO-EVAL] Evaluated activity {eid} on INSERT")
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to evaluate activity {eid}: {eval_error}")
insert_activity_from_entry(cur, pid, eid, e)
run_activity_post_write_hooks(cur, pid, eid)
# Phase 2: Increment usage counter (always for new entries)
increment_feature_usage(pid, 'activity_entries')
@ -133,36 +249,127 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
return {"id":eid,"date":e.date}
@router.get("/stats")
def activity_stats(
skip_quality_filter: bool = Query(
False,
description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).",
),
session: dict = Depends(require_auth),
):
"""Get activity statistics (last 30 entries)."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
if skip_quality_filter:
quality_filter = ""
else:
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {}, "")
cur.execute(
f"SELECT COUNT(*)::bigint AS c FROM activity_log WHERE profile_id=%s {quality_filter}",
(pid,),
)
total_in_profile = int(cur.fetchone()["c"])
if skip_quality_filter:
cur.execute(
f"""
SELECT d.* FROM (
SELECT al.*, ROW_NUMBER() OVER (
{_ACTIVITY_DEDUP_WINDOW}
) AS _dup_rn
FROM activity_log al
WHERE al.profile_id = %s
) d
WHERE d._dup_rn = 1
ORDER BY d.date DESC, d.start_time DESC NULLS LAST, d.id DESC
LIMIT 30
""",
(pid,),
)
rows = _activity_rows_after_list_query(cur)
else:
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s {quality_filter}
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT 30
""",
(pid,),
)
rows = [r2d(r) for r in cur.fetchall()]
if not rows:
return {
"count": 0,
"sample_size": 0,
"total_in_profile": total_in_profile,
"total_kcal": 0,
"total_min": 0,
"by_type": {},
}
total_kcal = sum(float(r.get("kcal_active") or 0) for r in rows)
total_min = sum(float(r.get("duration_min") or 0) for r in rows)
by_type = {}
for r in rows:
t = r["activity_type"]
by_type.setdefault(t, {"count": 0, "kcal": 0, "min": 0})
by_type[t]["count"] += 1
by_type[t]["kcal"] += float(r.get("kcal_active") or 0)
by_type[t]["min"] += float(r.get("duration_min") or 0)
return {
"count": len(rows),
"sample_size": len(rows),
"total_in_profile": total_in_profile,
"total_kcal": round(total_kcal),
"total_min": round(total_min),
"by_type": by_type,
}
@router.get("/uncategorized")
def list_uncategorized_activities(
session: dict = Depends(require_auth),
):
"""Get activities without assigned training type, grouped by activity_type."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT activity_type, COUNT(*) as count,
MIN(date) as first_date, MAX(date) as last_date
FROM activity_log
WHERE profile_id=%s AND training_type_id IS NULL
GROUP BY activity_type
ORDER BY count DESC
""",
(pid,),
)
return [r2d(r) for r in cur.fetchall()]
@router.get("/mappable-fields")
def get_activity_mappable_fields(session: dict = Depends(require_auth)):
"""
Vollständiger Katalog für Import-Mappings (activity_log-Kernfelder + alle aktiven training_parameters).
Werte für Keys ohne Schema zur konkreten Session werden beim Import ignoriert.
"""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
return get_mappable_activity_field_catalog(cur, pid)
@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."""
pid = get_pid(x_profile_id)
with get_db() as conn:
d = e.model_dump()
cur = get_cursor(conn)
cur.execute(f"UPDATE activity_log SET {', '.join(f'{k}=%s' for k in d)} WHERE id=%s AND profile_id=%s",
list(d.values())+[eid,pid])
# Phase 1.2: Auto-evaluation after UPDATE
if EVALUATION_AVAILABLE:
# Load the updated activity data to evaluate
cur.execute("""
SELECT id, profile_id, date, training_type_id, duration_min,
hr_avg, hr_max, distance_km, kcal_active, kcal_resting,
rpe, pace_min_per_km, cadence, elevation_gain
FROM activity_log
WHERE id = %s
""", (eid,))
activity_row = cur.fetchone()
if activity_row:
activity_dict = dict(activity_row)
training_type_id = activity_dict.get("training_type_id")
if training_type_id:
try:
evaluate_and_save_activity(cur, eid, activity_dict, training_type_id, pid)
logger.info(f"[AUTO-EVAL] Re-evaluated activity {eid} on UPDATE")
except Exception as eval_error:
logger.error(f"[AUTO-EVAL] Failed to re-evaluate activity {eid}: {eval_error}")
update_activity_from_entry(cur, pid, eid, e)
run_activity_post_write_hooks(cur, pid, eid)
return {"id":eid}
@ -177,25 +384,53 @@ def delete_activity(eid: str, x_profile_id: Optional[str]=Header(default=None),
return {"ok":True}
@router.get("/stats")
def activity_stats(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get activity statistics (last 30 entries)."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT * FROM activity_log WHERE profile_id=%s ORDER BY date DESC LIMIT 30", (pid,))
rows = [r2d(r) for r in cur.fetchall()]
if not rows: return {"count":0,"total_kcal":0,"total_min":0,"by_type":{}}
total_kcal=sum(float(r.get('kcal_active') or 0) for r in rows)
total_min=sum(float(r.get('duration_min') or 0) for r in rows)
by_type={}
for r in rows:
t=r['activity_type']; by_type.setdefault(t,{'count':0,'kcal':0,'min':0})
by_type[t]['count']+=1
by_type[t]['kcal']+=float(r.get('kcal_active') or 0)
by_type[t]['min']+=float(r.get('duration_min') or 0)
return {"count":len(rows),"total_kcal":round(total_kcal),"total_min":round(total_min),"by_type":by_type}
@router.put("/{eid}/metrics")
def replace_activity_metrics(
eid: str,
body: ActivityMetricsReplace,
session: dict = Depends(require_auth),
):
"""
Voller Ersatz der EAV-Session-Metriken (siehe ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md).
"""
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
replace_activity_session_metrics,
)
pid = str(session["profile_id"])
payload = [m.model_dump() for m in body.metrics]
try:
with get_db() as conn:
cur = get_cursor(conn)
metrics = replace_activity_session_metrics(cur, pid, eid, payload)
conn.commit()
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
return {"id": eid, "metrics": metrics}
@router.get("/{eid}")
def get_activity_session(
eid: str,
session: dict = Depends(require_auth),
):
"""Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1)."""
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
get_activity_session_logical_unit,
)
from data_layer.utils import serialize_dates
pid = str(session["profile_id"])
try:
with get_db() as conn:
cur = get_cursor(conn)
unit = get_activity_session_logical_unit(cur, pid, eid)
except ActivitySessionMetricsError as err:
raise HTTPException(err.status_code, err.detail) from err
unit["header"] = serialize_dates(unit["header"])
return unit
def get_training_type_for_activity_with_cursor(cur, activity_type: str, profile_id: str | None = None):
@ -251,23 +486,6 @@ def get_training_type_for_activity(activity_type: str, profile_id: str = None):
return get_training_type_for_activity_with_cursor(cur, activity_type, profile_id)
@router.get("/uncategorized")
def list_uncategorized_activities(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get activities without assigned training type, grouped by activity_type."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
SELECT activity_type, COUNT(*) as count,
MIN(date) as first_date, MAX(date) as last_date
FROM activity_log
WHERE profile_id=%s AND training_type_id IS NULL
GROUP BY activity_type
ORDER BY count DESC
""", (pid,))
return [r2d(r) for r in cur.fetchall()]
@router.post("/bulk-categorize")
def bulk_categorize_activities(
data: dict,
@ -353,7 +571,10 @@ def bulk_categorize_activities(
@router.post("/import-csv")
async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Import Apple Health workout CSV with automatic training type mapping."""
"""
Legacy-Upload (Apple Health Workout-CSV-Spaltennamen).
Persistenz läuft über activity_persistence_orchestrator gleiche Schicht wie Universal-CSV.
"""
pid = get_pid(x_profile_id)
raw = await file.read()
try: text = raw.decode('utf-8')
@ -367,9 +588,11 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
for row in reader:
wtype = row.get('Workout Type','').strip()
start = row.get('Start','').strip()
if not wtype or not start: continue
try: date = start[:10]
except: continue
if not wtype or not start:
continue
workout_date, workout_start_t = normalize_activity_start(start)
if not workout_date:
continue
dur = row.get('Duration','').strip()
duration_min = None
if dur:
@ -386,106 +609,82 @@ async def import_activity_csv(file: UploadFile=File(...), x_profile_id: Optional
# Map activity_type to training_type_id using database mappings
training_type_id, training_category, training_subcategory = get_training_type_for_activity(wtype, pid)
kcal_a = kj(row.get("Aktive Energie (kJ)", ""))
kcal_r = kj(row.get("Ruheeinträge (kJ)", ""))
hr_av = tf(row.get("Durchschn. Herzfrequenz (count/min)", ""))
hr_mx = tf(row.get("Max. Herzfrequenz (count/min)", ""))
dist_km = tf(row.get("Distanz (km)", ""))
try:
# Check if entry already exists (duplicate detection by date + start_time)
cur.execute("""
SELECT id FROM activity_log
WHERE profile_id = %s AND date = %s AND start_time = %s
""", (pid, date, start))
existing = cur.fetchone()
if existing:
# Update existing entry (e.g., to add training type mapping)
existing_id = existing['id']
cur.execute("""
UPDATE activity_log
SET end_time = %s,
activity_type = %s,
duration_min = %s,
kcal_active = %s,
kcal_resting = %s,
hr_avg = %s,
hr_max = %s,
distance_km = %s,
training_type_id = %s,
training_category = %s,
training_subcategory = %s
WHERE id = %s
""", (
row.get('End',''), wtype, duration_min,
kj(row.get('Aktive Energie (kJ)','')),
kj(row.get('Ruheeinträge (kJ)','')),
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')),
tf(row.get('Distanz (km)','')),
training_type_id, training_category, training_subcategory,
existing_id
))
skipped += 1 # Count as skipped (not newly inserted)
# Phase 1.2: Auto-evaluation after CSV import UPDATE
if EVALUATION_AVAILABLE and training_type_id:
try:
# Build activity dict for evaluation
activity_dict = {
"id": existing_id,
"profile_id": pid,
"date": date,
"training_type_id": training_type_id,
"duration_min": duration_min,
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
"distance_km": tf(row.get('Distanz (km)','')),
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None
}
evaluate_and_save_activity(cur, existing_id, activity_dict, training_type_id, pid)
logger.debug(f"[AUTO-EVAL] Re-evaluated updated activity {existing_id}")
except Exception as eval_error:
logger.warning(f"[AUTO-EVAL] Failed to re-evaluate updated activity {existing_id}: {eval_error}")
existing_id = find_activity_duplicate_id(cur, pid, workout_date, workout_start_t)
if existing_id:
update_activity_columns(
cur,
pid,
str(existing_id),
{
"start_time": workout_start_t,
"end_time": row.get("End", "") or None,
"activity_type": wtype,
"duration_min": duration_min,
"kcal_active": kcal_a,
"kcal_resting": kcal_r,
"hr_avg": hr_av,
"hr_max": hr_mx,
"distance_km": dist_km,
"training_type_id": training_type_id,
"training_category": training_category,
"training_subcategory": training_subcategory,
},
)
skipped += 1
run_activity_post_write_hooks_import(
cur,
pid,
str(existing_id),
workout_date=workout_date,
training_type_id=training_type_id,
duration_min=duration_min,
hr_avg=hr_av,
hr_max=hr_mx,
distance_km=dist_km,
kcal_active=kcal_a,
kcal_resting=kcal_r,
)
else:
# Insert new entry
new_id = str(uuid.uuid4())
cur.execute("""INSERT INTO activity_log
(id,profile_id,date,start_time,end_time,activity_type,duration_min,kcal_active,kcal_resting,
hr_avg,hr_max,distance_km,source,training_type_id,training_category,training_subcategory,created)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'apple_health',%s,%s,%s,CURRENT_TIMESTAMP)""",
(new_id,pid,date,start,row.get('End',''),wtype,duration_min,
kj(row.get('Aktive Energie (kJ)','')),kj(row.get('Ruheeinträge (kJ)','')),
tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
tf(row.get('Max. Herzfrequenz (count/min)','')),
tf(row.get('Distanz (km)','')),
training_type_id,training_category,training_subcategory))
inserted+=1
# Phase 1.2: Auto-evaluation after CSV import INSERT
if EVALUATION_AVAILABLE and training_type_id:
try:
# Build activity dict for evaluation
activity_dict = {
"id": new_id,
"profile_id": pid,
"date": date,
"training_type_id": training_type_id,
"duration_min": duration_min,
"hr_avg": tf(row.get('Durchschn. Herzfrequenz (count/min)','')),
"hr_max": tf(row.get('Max. Herzfrequenz (count/min)','')),
"distance_km": tf(row.get('Distanz (km)','')),
"kcal_active": kj(row.get('Aktive Energie (kJ)','')),
"kcal_resting": kj(row.get('Ruheeinträge (kJ)','')),
"rpe": None,
"pace_min_per_km": None,
"cadence": None,
"elevation_gain": None
}
evaluate_and_save_activity(cur, new_id, activity_dict, training_type_id, pid)
logger.debug(f"[AUTO-EVAL] Evaluated imported activity {new_id}")
except Exception as eval_error:
logger.warning(f"[AUTO-EVAL] Failed to evaluate imported activity {new_id}: {eval_error}")
new_id = new_activity_id()
insert_activity_csv_minimal(
cur,
pid,
new_id,
date_iso=workout_date,
start_time=workout_start_t,
end_time=row.get("End", "") or None,
activity_type=wtype,
duration_min=duration_min,
kcal_active=kcal_a,
kcal_resting=kcal_r,
hr_avg=hr_av,
hr_max=hr_mx,
distance_km=dist_km,
training_type_id=training_type_id,
training_category=training_category,
training_subcategory=training_subcategory,
source="apple_health",
)
inserted += 1
run_activity_post_write_hooks_import(
cur,
pid,
new_id,
workout_date=workout_date,
training_type_id=training_type_id,
duration_min=duration_min,
hr_avg=hr_av,
hr_max=hr_mx,
distance_km=dist_km,
kcal_active=kcal_a,
kcal_resting=kcal_r,
)
except Exception as e:
logger.warning(f"Import row failed: {e}")
skipped+=1

View File

@ -0,0 +1,266 @@
"""
Admin: training_category_parameter + training_type_parameter (attribute profiles).
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from auth import require_admin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin", tags=["admin", "activity-attribute-profiles"])
class CategoryParameterCreate(BaseModel):
training_category: str = Field(..., min_length=1, max_length=50)
training_parameter_id: int
sort_order: int = 0
required: bool = False
ui_group: Optional[str] = Field(None, max_length=50)
class TypeParameterCreate(BaseModel):
training_type_id: int
training_parameter_id: int
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
class CategoryParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
class TypeParameterUpdate(BaseModel):
sort_order: Optional[int] = None
required: Optional[bool] = None
ui_group: Optional[str] = Field(None, max_length=50)
@router.get("/training-category-parameters")
def admin_list_category_parameters(
category: Optional[str] = Query(None, description="Filter: training_types.category"),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
if category:
cur.execute(
"""
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
WHERE tcp.training_category = %s
ORDER BY tcp.sort_order, tp.key
""",
(category.strip(),),
)
else:
cur.execute(
"""
SELECT tcp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_category_parameter tcp
JOIN training_parameters tp ON tp.id = tcp.training_parameter_id
ORDER BY tcp.training_category, tcp.sort_order, tp.key
"""
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/training-category-parameters")
def admin_add_category_parameter(
body: CategoryParameterCreate,
session: dict = Depends(require_admin),
):
cat = body.training_category.strip()
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
if not cur.fetchone():
raise HTTPException(404, "training_parameter_id unbekannt")
try:
cur.execute(
"""
INSERT INTO training_category_parameter (
training_category, training_parameter_id, sort_order, required, ui_group
) VALUES (%s,%s,%s,%s,%s)
RETURNING id
""",
(cat, body.training_parameter_id, body.sort_order, body.required, body.ui_group),
)
new_id = cur.fetchone()["id"]
conn.commit()
except Exception as e:
conn.rollback()
if "uq_training_category_parameter" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(409, "Zuordnung existiert bereits") from e
raise HTTPException(400, str(e)) from e
return {"id": new_id}
@router.put("/training-category-parameters/{link_id}")
def admin_update_category_parameter(
link_id: int,
body: CategoryParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_category_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-category-parameters/{link_id}")
def admin_delete_category_parameter(
link_id: int,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM training_category_parameter WHERE id = %s RETURNING id",
(link_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True}
@router.get("/training-type-parameters")
def admin_list_type_parameters(
training_type_id: int = Query(..., ge=1),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT ttp.*, tp.key AS parameter_key, tp.name_de AS parameter_name_de
FROM training_type_parameter ttp
JOIN training_parameters tp ON tp.id = ttp.training_parameter_id
WHERE ttp.training_type_id = %s
ORDER BY ttp.sort_order NULLS LAST, tp.key
""",
(training_type_id,),
)
return [r2d(r) for r in cur.fetchall()]
@router.post("/training-type-parameters")
def admin_add_type_parameter(
body: TypeParameterCreate,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM training_types WHERE id = %s", (body.training_type_id,))
if not cur.fetchone():
raise HTTPException(404, "training_type_id unbekannt")
cur.execute("SELECT id FROM training_parameters WHERE id = %s", (body.training_parameter_id,))
if not cur.fetchone():
raise HTTPException(404, "training_parameter_id unbekannt")
try:
cur.execute(
"""
INSERT INTO training_type_parameter (
training_type_id, training_parameter_id, sort_order, required, ui_group
) VALUES (%s,%s,%s,%s,%s)
RETURNING id
""",
(
body.training_type_id,
body.training_parameter_id,
body.sort_order,
body.required,
body.ui_group,
),
)
new_id = cur.fetchone()["id"]
conn.commit()
except Exception as e:
conn.rollback()
if "uq_training_type_parameter" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(409, "Zuordnung existiert bereits") from e
raise HTTPException(400, str(e)) from e
return {"id": new_id}
@router.put("/training-type-parameters/{link_id}")
def admin_update_type_parameter(
link_id: int,
body: TypeParameterUpdate,
session: dict = Depends(require_admin),
):
patch = body.model_dump(exclude_unset=True)
if not patch:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
cols: list[str] = []
vals: list = []
if "sort_order" in patch:
cols.append("sort_order = %s")
vals.append(patch["sort_order"])
if "required" in patch:
cols.append("required = %s")
vals.append(patch["required"])
if "ui_group" in patch:
cols.append("ui_group = %s")
vals.append(patch["ui_group"].strip() if patch["ui_group"] else None)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(link_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_type_parameter SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True, "id": link_id}
@router.delete("/training-type-parameters/{link_id}")
def admin_delete_type_parameter(
link_id: int,
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"DELETE FROM training_type_parameter WHERE id = %s RETURNING id",
(link_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Eintrag nicht gefunden")
conn.commit()
return {"ok": True}

View File

@ -25,6 +25,7 @@ from csv_parser.import_row_processing import (
)
from csv_parser.module_registry import get_module_definition
from csv_parser.template_validator import validate_csv_template
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
router = APIRouter(prefix="/api/admin/csv-templates", tags=["admin", "csv-import"])
@ -181,6 +182,8 @@ async def admin_analyze_csv_for_template(
sig = column_signature(headers)
seed_row: dict | None = None
field_mappings: dict = {}
type_conversions: dict = {}
with get_db() as conn:
cur = get_cursor(conn)
if seed_template_id is not None:
@ -215,15 +218,30 @@ async def admin_analyze_csv_for_template(
if best and best_key[0] > 0:
seed_row = best
seed_fm = (seed_row or {}).get("field_mappings") or {}
if isinstance(seed_fm, str):
seed_fm = {}
seed_tc = (seed_row or {}).get("type_conversions")
if not isinstance(seed_tc, dict):
seed_tc = {}
mod_def = get_module_definition(module) or {}
eff_fields = dict(mod_def.get("fields") or {})
if module == "activity":
eff_fields = merge_activity_csv_module_fields(cur, eff_fields)
field_mappings = suggest_field_mappings(headers, module, seed_fm if seed_fm else None)
type_conversions = build_type_conversions_for_mapping(module, field_mappings, seed_tc if seed_tc else None)
seed_fm = (seed_row or {}).get("field_mappings") or {}
if isinstance(seed_fm, str):
seed_fm = {}
seed_tc = (seed_row or {}).get("type_conversions")
if not isinstance(seed_tc, dict):
seed_tc = {}
field_mappings = suggest_field_mappings(
headers,
module,
seed_fm if seed_fm else None,
effective_fields=eff_fields,
)
type_conversions = build_type_conversions_for_mapping(
module,
field_mappings,
seed_tc if seed_tc else None,
effective_fields=eff_fields,
)
seed_meta = None
if seed_row:
@ -270,13 +288,16 @@ def validate_system_template_dry_run(body: CsvTemplateValidateBody, session: dic
"""
if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
return validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
)
with get_db() as conn:
cur = get_cursor(conn)
return validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
cur=cur,
)
@router.get("/{template_id}")
@ -297,18 +318,19 @@ def get_system_template(template_id: int, session: dict = Depends(require_admin)
def create_system_template(body: CsvSystemTemplateCreate, session: dict = Depends(require_admin)):
if not get_module_definition(body.module):
raise HTTPException(400, f"Unbekanntes Modul: {body.module}")
report = validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
)
if not report["valid"]:
raise HTTPException(status_code=422, detail=report)
with get_db() as conn:
cur = get_cursor(conn)
report = validate_csv_template(
body.module,
body.field_mappings,
body.type_conversions,
body.import_row_processing,
body.column_signature,
cur=cur,
)
if not report["valid"]:
raise HTTPException(status_code=422, detail=report)
cur.execute(
"""
INSERT INTO csv_field_mappings (
@ -367,6 +389,7 @@ def update_system_template(
tc_eff,
irp_eff,
col_eff if isinstance(col_eff, list) else None,
cur=cur,
)
if not report["valid"]:
raise HTTPException(status_code=422, detail=report)

View File

@ -0,0 +1,215 @@
"""
Admin: training_parameters catalog (EAV keys for activity session metrics).
Agent guide: .claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md
"""
import re
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2 import errors as pg_errors
from psycopg2.extras import Json
from auth import require_admin
from db import get_db, get_cursor, r2d
router = APIRouter(prefix="/api/admin/training-parameters", tags=["admin", "training-parameters"])
KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$")
PARAM_CATEGORY = {"physical", "physiological", "subjective", "environmental", "performance"}
DATA_TYPES = {"integer", "float", "string", "boolean"}
class TrainingParameterCreate(BaseModel):
key: str = Field(..., min_length=1, max_length=50)
name_de: str = Field(..., min_length=1, max_length=100)
name_en: str = Field(..., min_length=1, max_length=100)
category: str = Field(..., max_length=50)
data_type: str = Field(..., max_length=20)
unit: Optional[str] = Field(None, max_length=20)
description_de: Optional[str] = None
description_en: Optional[str] = None
source_field: Optional[str] = Field(None, max_length=100)
validation_rules: Optional[dict] = None
is_active: bool = True
class TrainingParameterUpdate(BaseModel):
name_de: Optional[str] = Field(None, min_length=1, max_length=100)
name_en: Optional[str] = Field(None, min_length=1, max_length=100)
category: Optional[str] = Field(None, max_length=50)
data_type: Optional[str] = Field(None, max_length=20)
unit: Optional[str] = Field(None, max_length=20)
description_de: Optional[str] = None
description_en: Optional[str] = None
source_field: Optional[str] = Field(None, max_length=100)
validation_rules: Optional[dict] = None
is_active: Optional[bool] = None
def _norm_key(key: str) -> str:
k = key.strip().lower()
if not KEY_PATTERN.match(k):
raise HTTPException(
400,
"Ungültiger key: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.",
)
return k
def _validate_category(cat: str) -> str:
c = cat.strip()
if c not in PARAM_CATEGORY:
raise HTTPException(400, f"category muss einer von {sorted(PARAM_CATEGORY)} sein")
return c
def _validate_data_type(dt: str) -> str:
d = dt.strip().lower()
if d not in DATA_TYPES:
raise HTTPException(400, f"data_type muss einer von {sorted(DATA_TYPES)} sein")
return d
@router.get("")
def admin_list_training_parameters(
include_inactive: bool = Query(False),
session: dict = Depends(require_admin),
):
with get_db() as conn:
cur = get_cursor(conn)
if include_inactive:
cur.execute(
"""
SELECT * FROM training_parameters
ORDER BY category, key
"""
)
else:
cur.execute(
"""
SELECT * FROM training_parameters
WHERE is_active = true
ORDER BY category, key
"""
)
return [r2d(r) for r in cur.fetchall()]
@router.post("")
def admin_create_training_parameter(
body: TrainingParameterCreate,
session: dict = Depends(require_admin),
):
key = _norm_key(body.key)
cat = _validate_category(body.category)
dt = _validate_data_type(body.data_type)
rules = body.validation_rules if body.validation_rules is not None else {}
with get_db() as conn:
cur = get_cursor(conn)
try:
cur.execute(
"""
INSERT INTO training_parameters (
key, name_de, name_en, category, data_type, unit,
description_de, description_en, source_field, validation_rules, is_active
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING id
""",
(
key,
body.name_de.strip(),
body.name_en.strip(),
cat,
dt,
body.unit.strip() if body.unit else None,
body.description_de,
body.description_en,
body.source_field.strip() if body.source_field else None,
Json(rules),
body.is_active,
),
)
new_id = cur.fetchone()["id"]
conn.commit()
except pg_errors.UniqueViolation:
conn.rollback()
raise HTTPException(409, "Parameter-key existiert bereits") from None
return {"id": new_id, "key": key}
@router.put("/{param_id}")
def admin_update_training_parameter(
param_id: int,
body: TrainingParameterUpdate,
session: dict = Depends(require_admin),
):
cols: list[str] = []
vals: list[Any] = []
if body.name_de is not None:
cols.append("name_de = %s")
vals.append(body.name_de.strip())
if body.name_en is not None:
cols.append("name_en = %s")
vals.append(body.name_en.strip())
if body.category is not None:
cols.append("category = %s")
vals.append(_validate_category(body.category))
if body.data_type is not None:
cols.append("data_type = %s")
vals.append(_validate_data_type(body.data_type))
if body.unit is not None:
cols.append("unit = %s")
vals.append(body.unit.strip() or None)
if body.description_de is not None:
cols.append("description_de = %s")
vals.append(body.description_de)
if body.description_en is not None:
cols.append("description_en = %s")
vals.append(body.description_en)
if body.source_field is not None:
cols.append("source_field = %s")
vals.append(body.source_field.strip() or None)
if body.validation_rules is not None:
cols.append("validation_rules = %s")
vals.append(Json(body.validation_rules))
if body.is_active is not None:
cols.append("is_active = %s")
vals.append(body.is_active)
if not cols:
raise HTTPException(400, "Keine Felder zum Aktualisieren")
vals.append(param_id)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
f"UPDATE training_parameters SET {', '.join(cols)} WHERE id = %s RETURNING id",
vals,
)
if not cur.fetchone():
raise HTTPException(404, "Parameter nicht gefunden")
conn.commit()
return {"ok": True, "id": param_id}
@router.delete("/{param_id}")
def admin_deactivate_training_parameter(
param_id: int,
session: dict = Depends(require_admin),
):
"""Soft-delete: is_active = false (FK von session_metrics verhindert hartes Löschen)."""
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"UPDATE training_parameters SET is_active = false WHERE id = %s RETURNING id",
(param_id,),
)
if not cur.fetchone():
raise HTTPException(404, "Parameter nicht gefunden")
conn.commit()
return {"ok": True, "id": param_id}

View File

@ -34,6 +34,7 @@ from csv_parser.type_converter import build_row_after_mapping, diagnose_row_mapp
from csv_parser.field_units import source_unit_choices_for_field
from csv_parser.import_errors import enrich_row_error
from csv_parser.module_registry import get_module_definition, list_modules, validate_field_mappings
from data_layer.activity_persistence_orchestrator import merge_activity_csv_module_fields
from csv_parser.sleep_apple_import import detect_apple_sleep_csv_format
router = APIRouter(prefix="/api/csv", tags=["csv-import"])
@ -66,25 +67,30 @@ def _mapping_to_summary(m: dict) -> dict:
def csv_modules(session: dict = Depends(require_auth)):
"""Unterstützte Import-Module und Felddefinitionen."""
out = []
for mid in list_modules():
d = get_module_definition(mid)
if d:
fields_out = {}
for fname, finfo in (d.get("fields") or {}).items():
fd = dict(finfo)
opts = source_unit_choices_for_field(mid, fname)
if opts:
fd["source_unit_options"] = opts
fields_out[fname] = fd
out.append(
{
"id": mid,
"table": d["table"],
"fields": fields_out,
"import_mode": d.get("import_mode"),
"import_row_processing_default": d.get("import_row_processing_default"),
}
)
with get_db() as conn:
cur = get_cursor(conn)
for mid in list_modules():
d = get_module_definition(mid)
if d:
field_src = dict(d.get("fields") or {})
if mid == "activity":
field_src = merge_activity_csv_module_fields(cur, field_src)
fields_out = {}
for fname, finfo in field_src.items():
fd = dict(finfo)
opts = source_unit_choices_for_field(mid, fname)
if opts:
fd["source_unit_options"] = opts
fields_out[fname] = fd
out.append(
{
"id": mid,
"table": d["table"],
"fields": fields_out,
"import_mode": d.get("import_mode"),
"import_row_processing_default": d.get("import_row_processing_default"),
}
)
return {"modules": out}
@ -257,6 +263,10 @@ async def analyze_csv(
with get_db() as conn:
cur = get_cursor(conn)
if module == "activity" and mod_def:
available_fields = merge_activity_csv_module_fields(
cur, dict(mod_def.get("fields") or {})
)
if module:
cur.execute(
"""

View File

@ -0,0 +1,204 @@
"""Unit tests for data_layer.activity_session_metrics (no DB for most cases)."""
import uuid
import pytest
from data_layer.activity_session_metrics import (
ActivitySessionMetricsError,
enrich_sessions_with_metrics,
merge_parameter_schema_rows,
resolve_activity_attribute_schema,
_row_value_tuple,
_validate_single_value,
)
def _tp_row(
pid: int,
key: str,
*,
data_type: str = "integer",
cat_sort: int = 0,
cat_required: bool = False,
name_de: str = "X",
name_en: str = "X",
param_category: str = "physical",
unit: str | None = None,
validation_rules: dict | None = None,
source_field: str | None = None,
):
return {
"training_parameter_id": pid,
"cat_sort": cat_sort,
"cat_required": cat_required,
"cat_ui_group": None,
"key": key,
"name_de": name_de,
"name_en": name_en,
"param_category": param_category,
"data_type": data_type,
"unit": unit,
"validation_rules": validation_rules or {},
"source_field": source_field,
}
def _ttp_row(
pid: int,
key: str,
*,
typ_sort: int | None = None,
typ_required: bool | None = None,
typ_ui_group: str | None = None,
data_type: str = "integer",
name_de: str = "X",
name_en: str = "X",
param_category: str = "physical",
unit: str | None = None,
validation_rules: dict | None = None,
source_field: str | None = None,
):
return {
"training_parameter_id": pid,
"typ_sort": typ_sort,
"typ_required": typ_required,
"typ_ui_group": typ_ui_group,
"key": key,
"name_de": name_de,
"name_en": name_en,
"param_category": param_category,
"data_type": data_type,
"unit": unit,
"validation_rules": validation_rules or {},
"source_field": source_field,
}
def test_merge_category_only_sorted_by_sort_order_then_key():
cat = [
_tp_row(2, "zebra", cat_sort=10),
_tp_row(1, "alpha", cat_sort=5),
]
merged = merge_parameter_schema_rows(cat, [])
assert [m["key"] for m in merged] == ["alpha", "zebra"]
assert merged[0]["required"] is False
def test_merge_type_overrides_required_and_sort():
cat = [_tp_row(1, "rpe", cat_sort=0, cat_required=False)]
typ = [_ttp_row(1, "rpe", typ_sort=99, typ_required=True)]
merged = merge_parameter_schema_rows(cat, typ)
assert len(merged) == 1
assert merged[0]["sort_order"] == 99
assert merged[0]["required"] is True
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)
assert len(merged) == 1
assert merged[0]["key"] == "cadence"
assert merged[0]["required"] is True
def test_validate_integer_range():
_validate_single_value("integer", 5, {"min": 1, "max": 10})
with pytest.raises(ActivitySessionMetricsError) as ei:
_validate_single_value("integer", 0, {"min": 1})
assert ei.value.status_code == 400
def test_validate_float_accepts_int():
_validate_single_value("float", 3, {"min": 0, "max": 10})
def test_validate_boolean_rejects_int():
with pytest.raises(ActivitySessionMetricsError):
_validate_single_value("boolean", 1, {})
def test_row_value_tuple_mapping():
assert _row_value_tuple("integer", 42) == (None, 42, None, None)
assert _row_value_tuple("float", 1.5) == (1.5, None, None, None)
assert _row_value_tuple("string", "hi") == (None, None, "hi", None)
assert _row_value_tuple("boolean", True) == (None, None, None, True)
class _FakeCursor:
"""Sequences fetchone/fetchall for resolve_activity_attribute_schema."""
def __init__(self, fetchone_chain, fetchall_chain):
self._fetchone = list(fetchone_chain)
self._fetchall = list(fetchall_chain)
self.executes: list[tuple] = []
def execute(self, sql, params=None):
self.executes.append((sql, params))
def fetchone(self):
return self._fetchone.pop(0)
def fetchall(self):
return self._fetchall.pop(0)
def test_resolve_with_explicit_category_no_type():
cur = _FakeCursor(
fetchone_chain=[],
fetchall_chain=[
[
_tp_row(1, "rpe", cat_sort=0),
],
],
)
out = resolve_activity_attribute_schema(cur, "cardio", None)
assert len(out) == 1
assert out[0]["key"] == "rpe"
assert len(cur.executes) == 1
def test_resolve_loads_category_from_training_type_id():
cur = _FakeCursor(
fetchone_chain=[{"category": "strength"}],
fetchall_chain=[
[_tp_row(1, "rpe", cat_sort=0)],
[],
],
)
out = resolve_activity_attribute_schema(cur, None, 42)
assert len(out) == 1
assert cur.executes[0][1] == (42,)
def test_enrich_sessions_batch():
aid = str(uuid.uuid4())
bid = str(uuid.uuid4())
class _Cur:
def __init__(self):
self.params = None
def execute(self, sql, params=None):
self.sql = sql
self.params = params
def fetchall(self):
return [
{
"activity_log_id": uuid.UUID(aid),
"key": "rpe",
"data_type": "integer",
"unit": None,
"value_num": None,
"value_int": 7,
"value_text": None,
"value_bool": None,
},
]
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[1]["session_metrics"] == []

View File

@ -249,11 +249,18 @@ def test_run_universal_import_activity_garmin_time_plus_date_columns(monkeypatch
cur = _SeqCursor([None, {"id": new_id}])
out = run_universal_csv_import(cur, PID, "activity", text, "garmin.csv", mapping)
assert out["rows_imported"] == 1
# Duplicate-Key muss Datum + kombinierte Startzeit enthalten
assert any(
params and "2024-01-20 08:30:00" in str(params)
# Duplicate-Check: Datum + TIME, IS NOT DISTINCT FROM (kein String-Vergleich für start_time)
dup_sqls = [
(_sql, params)
for _sql, params in cur.executes
if params
if params and "IS NOT DISTINCT FROM" in _sql and "activity_log" in _sql
]
assert dup_sqls, f"erwarteter Duplicate-SELECT fehlt; executes={cur.executes!r}"
assert any(
len(p) >= 3
and str(p[1]).startswith("2024-01-20")
and (getattr(p[2], "hour", None) == 8 and getattr(p[2], "minute", None) == 30)
for _, p in dup_sqls
)

View File

@ -38,6 +38,29 @@ def test_parse_csv_sample_header():
assert rows[0]["kcal"] == "2000"
def test_parse_csv_sample_nbsp_in_header_matches_normal_space_key():
"""Excel/Apple: NBSP (U+00A0) im Spaltennamen — gleicher Key wie normales Leerzeichen."""
text = "Aufgestiegene\u00a0Höhe (m);Wert\n12;3\n"
headers, rows, delim = parse_csv_sample(text, delimiter=";", max_data_rows=3)
assert headers == ["Aufgestiegene Höhe (m)", "Wert"]
assert rows[0]["Aufgestiegene Höhe (m)"] == "12"
def test_iter_csv_dict_rows_nbsp_header_canonical():
text = "col\u00a0one;b\n1;2\n"
rows = list(iter_csv_dict_rows(text, ";", has_header=True))
assert rows == [{"col one": "1", "b": "2"}]
def test_build_row_field_mapping_space_vs_nbsp_in_csv_header():
"""Vorlage (Dialog) mit normalem Leerzeichen, CSV mit NBSP — Zuordnung muss greifen."""
csv_row = {"Aufgestiegene\u00a0Höhe (m)": "10"}
fm = {"Aufgestiegene Höhe (m)": "stola"}
tc = {"stola": {"type": "float", "decimal_separator": ".", "flexible": True}}
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
assert out.get("stola") == 10.0
def test_column_signature_sorted_unique():
sig = column_signature(["B", "a", "a"])
assert sig == ["a", "b"]
@ -183,6 +206,16 @@ def test_build_row_fddb_raw_header_keys_match_normalized_template():
assert out["kcal"] is not None and abs(float(out["kcal"]) - (42000 / 4.184)) < 0.1
def test_build_row_apple_workout_elevation_header_prefix_matches_shorter_mapping_key():
"""Apple Workouts: „Aufgestiegene Höhe (m)“ normalisiert anders als manuell „aufgestiegene Höhe“."""
csv_row = {"Aufgestiegene Höhe (m)": "47.13", "Workout Type": "Outdoor Spaziergang"}
fm = {"aufgestiegene Höhe": "stola", "Workout Type": "activity_type"}
tc = {"stola": {"type": "string"}}
out = build_row_after_mapping(csv_row, fm, tc, module="activity")
assert out.get("stola") == "47.13"
assert out.get("activity_type") == "Outdoor Spaziergang"
def test_convert_date_ddmm_with_seconds():
d = convert_value(
"15.01.2024 14:30:00",

View File

@ -35,6 +35,7 @@ import AdminCouponsPage from './pages/AdminCouponsPage'
import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage'
import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage'
import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage'
import AdminActivityAttributeProfilesPage from './pages/AdminActivityAttributeProfilesPage'
import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
import AdminPromptsPage from './pages/AdminPromptsPage'
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
@ -255,6 +256,7 @@ function AppShell() {
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="activity-attribute-profiles" element={<AdminActivityAttributeProfilesPage />} />
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>
<Route path="prompts" element={<AdminPromptsPage/>}/>
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>

View File

@ -74,6 +74,11 @@ export const ADMIN_GROUPS = [
label: 'Trainings-Profile',
description: 'Training-Type-Profile (#15).',
},
{
to: '/admin/activity-attribute-profiles',
label: 'Session-Metriken (EAV)',
description: 'Messgrößen-Katalog und Zuordnung zu Kategorie / Trainingstyp.',
},
],
},
{

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useCallback, startTransition } from 'react'
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
@ -9,15 +9,98 @@ import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
/** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */
const ACTIVITY_MONTH_FETCH_LIMIT = 25_000
function ymdMonth(d = dayjs()) {
return d.format('YYYY-MM')
}
function prevMonthYm(ym) {
return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM')
}
function compareActivities(a, b) {
const da = a.date || ''
const db = b.date || ''
if (da !== db) return db.localeCompare(da)
const sa = String(a.start_time || '')
const sb = String(b.start_time || '')
if (sa !== sb) return sb.localeCompare(sa)
return String(b.id || '').localeCompare(String(a.id || ''))
}
function dedupeActivitiesById(rows) {
const m = new Map()
for (const r of rows) {
if (r?.id) m.set(r.id, r)
}
return [...m.values()].sort(compareActivities)
}
/** activity_log: Spalten start_time / end_time sind TIME (Uhrzeit zum Kalendertag date), nicht volles Timestamp. */
function timeInputValueFromApi(t) {
if (t == null || t === '') return ''
const s = String(t)
if (s.includes('T') && s.length >= 16) return s.slice(11, 16)
const m = s.match(/^(\d{1,2}):(\d{2})/)
if (!m) return ''
return `${m[1].padStart(2, '0')}:${m[2]}`
}
function timePayloadFromInput(v) {
const s = v == null ? '' : String(v).trim()
if (!s) return null
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
return s
}
function formatTimeForList(t) {
const v = timeInputValueFromApi(t)
return v || ''
}
const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
'Cardio Dance','Geist & Körper','Sonstiges'
]
/** Spalten, die mit ActivityEntry / UPDATE activity_log geschrieben werden dürfen (Übergang: Profilfelder → Kopfzeile). */
const ACTIVITY_LOG_PAYLOAD_KEYS = new Set([
'date',
'start_time',
'end_time',
'activity_type',
'duration_min',
'kcal_active',
'kcal_resting',
'hr_avg',
'hr_max',
'hr_min',
'distance_km',
'pace_min_per_km',
'cadence',
'avg_power',
'elevation_gain',
'temperature_celsius',
'humidity_percent',
'avg_hr_percent',
'kcal_per_km',
'rpe',
'source',
'notes',
'training_type_id',
'training_category',
'training_subcategory',
])
function empty() {
return {
date: dayjs().format('YYYY-MM-DD'),
start_time: '',
end_time: '',
activity_type: 'Traditionelles Krafttraining',
duration_min: '', kcal_active: '',
hr_avg: '', hr_max: '', rpe: '', notes: '',
@ -27,6 +110,126 @@ function empty() {
}
}
function buildMetricsPayload(schema, draft) {
const out = []
for (const s of schema) {
const raw = draft[s.key]
if (s.data_type === 'boolean') {
if (raw === '' || raw === null || raw === undefined) {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
out.push({ parameter_key: s.key, value: null })
continue
}
out.push({ parameter_key: s.key, value: !!raw })
continue
}
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
if (rawStr === '') {
if (s.required) throw new Error(`Pflichtfeld: ${s.name_de}`)
out.push({ parameter_key: s.key, value: null })
continue
}
let v
if (s.data_type === 'integer') {
v = parseInt(rawStr, 10)
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else if (s.data_type === 'float') {
v = parseFloat(rawStr)
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else {
v = rawStr
}
out.push({ parameter_key: s.key, value: v })
}
return out
}
function SessionMetricsFields({ schema, values, setValues, metrics }) {
const schemaList = Array.isArray(schema) ? schema : []
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))
if (schemaList.length === 0 && orphanMetrics.length === 0) return null
const set = (k, v) => setValues((prev) => ({ ...prev, [k]: v }))
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>
))}
{orphanMetrics.length > 0 && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
Werte aus Import/älteren Daten, die zum <strong>aktuellen</strong> Trainingsprofil dieser Session (Kategorie/Typ
in activity_log) nicht ins Schema passen nur Anzeige. Sichtbar nach erneutem Laden, wenn die Daten in der
Datenbank stehen.
</div>
{orphanMetrics.map((row) => {
const disp =
values[row.key] === null || values[row.key] === undefined || values[row.key] === ''
? '—'
: String(values[row.key])
return (
<div key={row.key} className="form-row">
<label className="form-label">
{row.key}
{row.unit ? ` (${row.unit})` : ''}
</label>
{row.data_type === 'boolean' ? (
<input type="checkbox" style={{ width: 'auto', marginRight: 'auto' }} checked={!!values[row.key]} readOnly disabled />
) : (
<div
className="form-input"
style={{
background: 'var(--surface2)',
cursor: 'default',
color: 'var(--text1)',
}}
>
{disp}
</div>
)}
<span className="form-unit" />
</div>
)
})}
</div>
)}
</div>
)
}
// Import Panel
function ImportPanel({ onImported }) {
const fileRef = useRef()
@ -85,7 +288,17 @@ function ImportPanel({ onImported }) {
}
// Manual Entry
function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) {
function EntryForm({
form,
setForm,
onSave,
onCancel,
saveLabel = 'Speichern',
saving = false,
error = null,
usage = null,
formExtras = null,
}) {
const set = (k,v) => setForm(f=>({...f,[k]:v}))
return (
<div>
@ -94,6 +307,30 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
<input type="date" className="form-input" style={{width:140}} value={form.date} onChange={e=>set('date',e.target.value)}/>
<span className="form-unit"/>
</div>
<div className="form-row">
<label className="form-label">Start (Uhrzeit)</label>
<input
type="time"
step={1}
className="form-input"
style={{ width: 'auto', minWidth: 140 }}
value={timeInputValueFromApi(form.start_time)}
onChange={(e) => set('start_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
/>
<span className="form-unit">zum Datum oben</span>
</div>
<div className="form-row">
<label className="form-label">Ende (Uhrzeit)</label>
<input
type="time"
step={1}
className="form-input"
style={{ width: 'auto', minWidth: 140 }}
value={timeInputValueFromApi(form.end_time)}
onChange={(e) => set('end_time', e.target.value ? timePayloadFromInput(e.target.value) || '' : '')}
/>
<span className="form-unit">optional</span>
</div>
<div style={{marginBottom:12}}>
<TrainingTypeSelect
value={form.training_type_id}
@ -144,6 +381,7 @@ function EntryForm({ form, setForm, onSave, onCancel, saveLabel='Speichern', sav
value={form.notes||''} onChange={e=>set('notes',e.target.value)}/>
<span className="form-unit"/>
</div>
{formExtras}
{error && (
<div style={{padding:'10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:13,color:'var(--danger)',marginBottom:8}}>
{error}
@ -181,12 +419,76 @@ export default function ActivityPage() {
const [error, setError] = useState(null)
const [activityUsage, setActivityUsage] = useState(null) // Phase 4: Usage badge
const [categories, setCategories] = useState({}) // v9d: Training categories
const [sessionDetail, setSessionDetail] = useState(null)
const [metricDraft, setMetricDraft] = useState({})
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false)
const [selectedMonth, setSelectedMonth] = useState(() => ymdMonth())
const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()])
const monthsIncludedRef = useRef(monthsIncluded)
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s)
useEffect(() => {
monthsIncludedRef.current = monthsIncluded
}, [monthsIncluded])
const fetchMonthsChain = useCallback(async (chain) => {
const lists = await Promise.all(
chain.map((ym) =>
api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
skipQualityFilter: true,
collapseDuplicateSessions: true,
month: ym,
})
)
)
const merged = dedupeActivitiesById(lists.flat())
const s = await api.activityStats({ skipQualityFilter: true })
setEntries(merged)
setStats(s)
}, [])
const load = useCallback(async () => {
await fetchMonthsChain(monthsIncludedRef.current)
}, [fetchMonthsChain])
const onMonthPickerChange = (e) => {
const ym = e.target.value
if (!ym) return
setSelectedMonth(ym)
const chain = [ym]
monthsIncludedRef.current = chain
setMonthsIncluded(chain)
void fetchMonthsChain(chain)
}
const loadPreviousMonth = async () => {
const chain = monthsIncludedRef.current
if (chain.length === 0) return
const oldest = chain[chain.length - 1]
const prev = prevMonthYm(oldest)
if (chain.includes(prev)) return
if (prev < '2000-01') return
setListLoadingMore(true)
try {
const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
skipQualityFilter: true,
collapseDuplicateSessions: true,
month: prev,
})
const newChain = [...chain, prev]
monthsIncludedRef.current = newChain
setMonthsIncluded(newChain)
setEntries((cur) => dedupeActivitiesById([...cur, ...more]))
} finally {
setListLoadingMore(false)
}
}
const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth
const nextOlderYm = prevMonthYm(oldestLoadedYm)
const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm)
const loadUsage = () => {
api.getFeatureUsage().then(features => {
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
@ -194,17 +496,69 @@ export default function ActivityPage() {
}).catch(err => console.error('Failed to load usage:', err))
}
useEffect(()=>{
load()
useEffect(() => {
const ym = ymdMonth()
monthsIncludedRef.current = [ym]
setMonthsIncluded([ym])
setSelectedMonth(ym)
void fetchMonthsChain([ym])
loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
},[])
}, [fetchMonthsChain])
useEffect(() => {
if (!editing?.id) {
setSessionDetail(null)
setMetricDraft({})
setSessionLoadError(null)
return
}
let cancelled = false
setSessionLoadError(null)
;(async () => {
try {
const d = await api.getActivitySession(editing.id)
if (!cancelled) setSessionDetail(d)
} catch (err) {
if (!cancelled) {
setSessionDetail(null)
setSessionLoadError(err.message || 'Zusatzfelder konnten nicht geladen werden')
}
}
})()
return () => { cancelled = true }
}, [editing?.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 : ''
}
}
setMetricDraft(m)
}, [sessionDetail])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = {...form}
payload.start_time =
payload.start_time === '' || payload.start_time == null
? null
: timePayloadFromInput(payload.start_time)
payload.end_time =
payload.end_time === '' || payload.end_time == null
? null
: timePayloadFromInput(payload.end_time)
if(payload.duration_min) payload.duration_min = parseFloat(payload.duration_min)
if(payload.kcal_active) payload.kcal_active = parseFloat(payload.kcal_active)
if(payload.hr_avg) payload.hr_avg = parseFloat(payload.hr_avg)
@ -226,9 +580,64 @@ export default function ActivityPage() {
}
const handleUpdate = async () => {
const payload = {...editing}
await api.updateActivity(editing.id, payload)
setEditing(null); await load()
setSavingEdit(true)
setError(null)
try {
const payload = { ...editing }
delete payload.id
for (const s of sessionDetail?.schema || []) {
const col = s.source_field
if (!col || !ACTIVITY_LOG_PAYLOAD_KEYS.has(col)) continue
if (!(s.key in metricDraft)) continue
const raw = metricDraft[s.key]
const rawStr = raw === null || raw === undefined ? '' : String(raw).trim()
if (rawStr === '') {
payload[col] = null
continue
}
let v = rawStr
if (s.data_type === 'integer') {
v = parseInt(rawStr, 10)
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else if (s.data_type === 'float') {
v = parseFloat(rawStr)
if (Number.isNaN(v)) throw new Error(`Ungültige Zahl: ${s.name_de}`)
} else if (s.data_type === 'boolean') {
v = !!raw
} else {
v = rawStr
}
payload[col] = v
}
if (payload.duration_min !== '' && payload.duration_min != null) payload.duration_min = parseFloat(payload.duration_min)
if (payload.kcal_active !== '' && payload.kcal_active != null) payload.kcal_active = parseFloat(payload.kcal_active)
if (payload.hr_avg !== '' && payload.hr_avg != null) payload.hr_avg = parseFloat(payload.hr_avg)
if (payload.hr_max !== '' && payload.hr_max != null) payload.hr_max = parseFloat(payload.hr_max)
if (payload.rpe !== '' && payload.rpe != null) payload.rpe = parseInt(payload.rpe, 10)
payload.start_time =
payload.start_time === '' || payload.start_time == null
? null
: timePayloadFromInput(payload.start_time)
payload.end_time =
payload.end_time === '' || payload.end_time == null
? null
: timePayloadFromInput(payload.end_time)
await api.updateActivity(editing.id, payload)
if (sessionDetail?.schema?.length > 0) {
const metrics = buildMetricsPayload(sessionDetail.schema, metricDraft)
await api.putActivityMetrics(editing.id, { metrics })
}
setEditing(null)
setSessionDetail(null)
startTransition(() => {
void load()
})
} catch (err) {
setError(err.message || 'Speichern fehlgeschlagen')
setTimeout(() => setError(null), 6000)
} finally {
setSavingEdit(false)
}
}
const handleDelete = async (id) => {
@ -266,12 +675,19 @@ export default function ActivityPage() {
</div>
{/* Übersicht */}
{stats && stats.count>0 && (
{stats && (stats.total_in_profile > 0 || stats.count > 0) && (
<div className="card section-gap">
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
<strong>{stats.total_in_profile ?? ''}</strong> Einträge im Profil (gleicher Filter wie diese Seite). Die Summen
Kcal/Stunden beziehen sich auf die <strong>neuesten {stats.sample_size ?? stats.count}</strong> Einträge (max.
30).
</div>
<div style={{display:'flex',gap:8,flexWrap:'wrap'}}>
{[['Trainings',stats.count,'var(--text1)'],
['Kcal gesamt',Math.round(stats.total_kcal),'#EF9F27'],
['Stunden',Math.round(stats.total_min/60*10)/10,'#378ADD']].map(([l,v,c])=>(
{[
['Neueste (max. 30)', stats.count, 'var(--text1)'],
['Kcal (darin)', Math.round(stats.total_kcal), '#EF9F27'],
['Stunden (darin)', Math.round(stats.total_min / 60 * 10) / 10, '#378ADD'],
].map(([l, v, c]) => (
<div key={l} style={{flex:1,minWidth:80,background:'var(--surface2)',borderRadius:8,padding:'8px 10px',textAlign:'center'}}>
<div style={{fontSize:18,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:10,color:'var(--text3)'}}>{l}</div>
@ -335,6 +751,37 @@ export default function ActivityPage() {
{tab==='list' && (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<label className="form-label" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
Monat
<input
type="month"
className="form-input"
value={selectedMonth}
onChange={onMonthPickerChange}
style={{ width: 'auto', minWidth: 150, margin: 0 }}
/>
</label>
{monthsIncluded.length > 1 && (
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
{dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
</span>
)}
</div>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}>
Hier sind <strong>alle</strong> Trainings sichtbar (Profil-Qualitätsfilter aus auch ohne Bewertung oder bei
abweichender Einordnung). Unter Verlauf / Auswertung bleibt der Filter aktiv. Es wird jeweils ein
kompletter Kalendermonat geladen; Vorheriger Monat hängt den nächstälteren Monat an (ohne OFFSET-Pagination).
</p>
{entries.length===0 && (
<div className="empty-state">
<h3>Keine Trainings</h3>
@ -347,8 +794,28 @@ export default function ActivityPage() {
return (
<div key={e.id} className="card" style={{marginBottom:8,borderLeft:`3px solid ${color}`}}>
{isEd ? (
<EntryForm form={editing} setForm={setEditing}
onSave={handleUpdate} onCancel={()=>setEditing(null)} saveLabel="Speichern"/>
<EntryForm
form={editing}
setForm={setEditing}
onSave={handleUpdate}
onCancel={() => { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }}
saveLabel="Speichern"
saving={savingEdit}
error={error}
formExtras={
<>
{sessionLoadError && (
<div style={{ fontSize: 12, color: 'var(--text3)', marginBottom: 8 }}>{sessionLoadError}</div>
)}
<SessionMetricsFields
schema={sessionDetail?.schema}
metrics={sessionDetail?.metrics}
values={metricDraft}
setValues={setMetricDraft}
/>
</>
}
/>
) : (
<div>
<div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
@ -435,7 +902,12 @@ export default function ActivityPage() {
</div>
<div style={{fontSize:11,color:'var(--text3)',marginBottom:4}}>
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
{e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
{(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && (
<span>
{formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`}
{formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`}
</span>
)}
</div>
<div style={{display:'flex',gap:10,flexWrap:'wrap'}}>
{e.duration_min && <span style={{fontSize:12,color:'var(--text2)'}}> {Math.round(e.duration_min)} Min</span>}
@ -458,6 +930,21 @@ export default function ActivityPage() {
</div>
)
})}
{canLoadOlder && (
<div style={{ marginTop: 12, marginBottom: 8 }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={listLoadingMore}
onClick={() => void loadPreviousMonth()}
>
{listLoadingMore
? 'Lade…'
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
</button>
</div>
)}
</div>
)}
</div>

View File

@ -0,0 +1,912 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react'
import { api } from '../utils/api'
const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
const DATA_TYPES = ['integer', 'float', 'string', 'boolean']
const emptyParamForm = () => ({
key: '',
name_de: '',
name_en: '',
category: 'physical',
data_type: 'float',
unit: '',
source_field: '',
is_active: true,
})
export default function AdminActivityAttributeProfilesPage() {
const [tab, setTab] = useState('catalog')
const [params, setParams] = useState([])
const [includeInactive, setIncludeInactive] = useState(false)
const [catMeta, setCatMeta] = useState({})
const [flatTypes, setFlatTypes] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [toast, setToast] = useState(null)
const [showParamForm, setShowParamForm] = useState(false)
const [paramForm, setParamForm] = useState(emptyParamForm())
const [editParam, setEditParam] = useState(null)
const [selCategory, setSelCategory] = useState('cardio')
const [catLinks, setCatLinks] = useState([])
const [catAdd, setCatAdd] = useState({
training_parameter_id: '',
sort_order: 0,
required: false,
ui_group: '',
})
const [editingCatId, setEditingCatId] = useState(null)
const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' })
const [selTypeId, setSelTypeId] = useState('')
const [typeLinks, setTypeLinks] = useState([])
const [typeAdd, setTypeAdd] = useState({
training_parameter_id: '',
sort_order: '',
required: '',
ui_group: '',
})
const [editingTypeId, setEditingTypeId] = useState(null)
const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
const showToast = (msg) => {
setToast(msg)
setTimeout(() => setToast(null), 2800)
}
const refreshCatalog = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [p, cats, flat] = await Promise.all([
api.adminListTrainingParameters(includeInactive),
api.getTrainingCategories(),
api.listTrainingTypesFlat(),
])
setParams(Array.isArray(p) ? p : [])
setCatMeta(cats && typeof cats === 'object' ? cats : {})
setFlatTypes(Array.isArray(flat) ? flat : [])
setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : ''))
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
} finally {
setLoading(false)
}
}, [includeInactive])
useEffect(() => {
refreshCatalog()
}, [refreshCatalog])
const loadCatLinks = useCallback(async () => {
try {
const data = await api.adminListTrainingCategoryParameters(selCategory)
setCatLinks(Array.isArray(data) ? data : [])
} catch (e) {
setError(e.message || 'Kategorie-Zuordnungen')
}
}, [selCategory])
useEffect(() => {
loadCatLinks()
}, [loadCatLinks])
const loadTypeLinks = useCallback(async () => {
if (!selTypeId) {
setTypeLinks([])
return
}
try {
const data = await api.adminListTrainingTypeParameters(Number(selTypeId))
setTypeLinks(Array.isArray(data) ? data : [])
} catch (e) {
setError(e.message || 'Typ-Zuordnungen')
}
}, [selTypeId])
useEffect(() => {
loadTypeLinks()
}, [loadTypeLinks])
const activeParams = params.filter((p) => p.is_active !== false)
const categoryKeys =
Object.keys(catMeta).length > 0
? Object.keys(catMeta).sort()
: ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
const saveNewParameter = async () => {
setError(null)
if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
setError('key, name_de und name_en sind Pflicht.')
return
}
try {
await api.adminCreateTrainingParameter({
key: paramForm.key.trim().toLowerCase(),
name_de: paramForm.name_de.trim(),
name_en: paramForm.name_en.trim(),
category: paramForm.category,
data_type: paramForm.data_type,
unit: paramForm.unit.trim() || null,
source_field: paramForm.source_field.trim() || null,
is_active: paramForm.is_active,
validation_rules: {},
})
showToast('Parameter angelegt')
setShowParamForm(false)
setParamForm(emptyParamForm())
await refreshCatalog()
} catch (e) {
setError(e.message || 'Speichern fehlgeschlagen')
}
}
const saveEditedParameter = async () => {
if (!editParam) return
setError(null)
try {
await api.adminUpdateTrainingParameter(editParam.id, {
name_de: editParam.name_de.trim(),
name_en: editParam.name_en.trim(),
category: editParam.category,
data_type: editParam.data_type,
unit: editParam.unit?.trim() || null,
source_field: editParam.source_field?.trim() || null,
is_active: !!editParam.is_active,
validation_rules: editParam.validation_rules || {},
})
showToast('Parameter gespeichert')
setEditParam(null)
await refreshCatalog()
} catch (e) {
setError(e.message || 'Update fehlgeschlagen')
}
}
const deactivateParameter = async (id) => {
if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
try {
await api.adminDeleteTrainingParameter(id)
showToast('Deaktiviert')
await refreshCatalog()
} catch (e) {
setError(e.message || 'Löschen fehlgeschlagen')
}
}
const addCatLink = async () => {
const pid = parseInt(catAdd.training_parameter_id, 10)
if (!pid) {
setError('Parameter-ID wählen')
return
}
try {
await api.adminAddTrainingCategoryParameter({
training_category: selCategory,
training_parameter_id: pid,
sort_order: Number(catAdd.sort_order) || 0,
required: !!catAdd.required,
ui_group: catAdd.ui_group.trim() || null,
})
showToast('Zuordnung gespeichert')
setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' })
await loadCatLinks()
} catch (e) {
setError(e.message || 'Konflikt oder ungültige Daten')
}
}
const saveCatLink = async (linkId) => {
try {
await api.adminUpdateTrainingCategoryParameter(linkId, {
sort_order: Number(catDraft.sort_order) || 0,
required: !!catDraft.required,
ui_group: catDraft.ui_group.trim() || null,
})
showToast('Kategorie-Zuordnung aktualisiert')
setEditingCatId(null)
await loadCatLinks()
} catch (e) {
setError(e.message || 'Update fehlgeschlagen')
}
}
const addTypeLink = async () => {
const tid = Number(selTypeId)
const pid = parseInt(typeAdd.training_parameter_id, 10)
if (!tid || !pid) {
setError('Trainingstyp und Parameter wählen')
return
}
const body = {
training_type_id: tid,
training_parameter_id: pid,
sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
required:
typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
ui_group: typeAdd.ui_group.trim() || null,
}
try {
await api.adminAddTrainingTypeParameter(body)
showToast('Typ-Zuordnung gespeichert')
setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' })
await loadTypeLinks()
} catch (e) {
setError(e.message || 'Konflikt oder ungültige Daten')
}
}
const saveTypeLink = async (linkId) => {
try {
const payload = {}
if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) {
payload.sort_order = Number(typeDraft.sort_order)
} else {
payload.sort_order = null
}
if (typeDraft.required === '' || typeDraft.required == null) {
payload.required = null
} else {
payload.required = typeDraft.required === true || typeDraft.required === 'true'
}
payload.ui_group = typeDraft.ui_group.trim() || null
await api.adminUpdateTrainingTypeParameter(linkId, payload)
showToast('Typ-Zuordnung aktualisiert')
setEditingTypeId(null)
await loadTypeLinks()
} catch (e) {
setError(e.message || 'Update fehlgeschlagen')
}
}
if (loading && !params.length) {
return (
<div className="card section-gap">
<div className="spinner" /> Lade
</div>
)
}
return (
<div className="capture-page">
<div style={{ marginBottom: 12 }}>
<Link to="/admin/g/training" className="text-link" style={{ fontSize: 13 }}>
Training (Hub)
</Link>
</div>
<h1 className="page-title">Session-Metriken (EAV)</h1>
<div
className="card section-gap"
style={{ background: 'var(--surface2)', border: '1px solid var(--border)', fontSize: 13, lineHeight: 1.55 }}
>
<strong>Hinweise</strong>
<ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
<li>
<strong>Daten:</strong> Offizielle Migrationen löschen keine Zeilen in <code>activity_log</code>. Leere
Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin vor Prod immer{' '}
<code>pg_dump</code>.
</li>
<li>
<strong>Doppelzuordnung:</strong> Dieselbe Metrik darf <em>eine</em> Zeile pro Kategorie und <em>eine</em>{' '}
pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in <strong>Kategorie</strong> und{' '}
<strong>Trainingstyp</strong> vorkommt, überschreibt die <strong>Typ-Zeile</strong> sort/required/ui_group
(Merge in Layer&nbsp;1) kein Datenfehler.
</li>
<li>
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>
</ul>
</div>
{toast && (
<div
className="card"
style={{ background: 'var(--accent-light)', color: 'var(--accent-dark)', marginBottom: 12 }}
>
{toast}
</div>
)}
{error && (
<div
className="card"
style={{ background: '#FCEBEB', color: '#D85A30', marginBottom: 12, fontSize: 14 }}
>
{error}
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setError(null)}>
OK
</button>
</div>
)}
<div className="tabs" style={{ marginBottom: 16, overflowX: 'auto' }}>
{[
['catalog', 'Katalog'],
['category', 'Kategorie'],
['type', 'Trainingstyp'],
].map(([id, label]) => (
<button
key={id}
type="button"
className={'tab' + (tab === id ? ' active' : '')}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
{tab === 'catalog' && (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>Parameter-Katalog</span>
<label style={{ fontSize: 12, fontWeight: 400, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={includeInactive}
onChange={(e) => setIncludeInactive(e.target.checked)}
/>
Inaktive
</label>
<button type="button" className="btn btn-secondary" onClick={() => refreshCatalog()}>
<RefreshCw size={14} /> Neu laden
</button>
<button type="button" className="btn btn-primary" onClick={() => setShowParamForm((v) => !v)}>
<Plus size={14} /> Neu
</button>
</div>
{showParamForm && (
<div
style={{
border: '1px solid var(--border)',
borderRadius: 8,
padding: 12,
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>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-primary" onClick={saveNewParameter}>
<Save size={14} /> Anlegen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowParamForm(false)}>
Abbrechen
</button>
</div>
</div>
)}
{editParam && (
<div
style={{
border: '1px solid var(--accent)',
borderRadius: 8,
padding: 12,
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>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 8 }}>
<input
type="checkbox"
checked={!!editParam.is_active}
onChange={(e) => setEditParam((p) => ({ ...p, is_active: e.target.checked }))}
/>
aktiv
</label>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" className="btn btn-primary" onClick={saveEditedParameter}>
<Save size={14} /> Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditParam(null)}>
Abbrechen
</button>
</div>
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table className="data-table" style={{ width: '100%', fontSize: 13 }}>
<thead>
<tr>
<th>ID</th>
<th>key</th>
<th>DE</th>
<th>Typ</th>
<th>aktiv</th>
<th />
</tr>
</thead>
<tbody>
{params.map((r) => (
<tr key={r.id}>
<td>{r.id}</td>
<td>
<code>{r.key}</code>
</td>
<td>{r.name_de}</td>
<td>
{r.data_type} · {r.category}
</td>
<td>{r.is_active === false ? 'nein' : 'ja'}</td>
<td>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => setEditParam({ ...r })}
>
<Pencil size={14} />
</button>
{r.is_active !== false && (
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={() => deactivateParameter(r.id)}
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{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>
<select
className="form-input"
value={selCategory}
onChange={(e) => setSelCategory(e.target.value)}
style={{ maxWidth: 280 }}
>
{categoryKeys.map((k) => (
<option key={k} value={k}>
{catMeta[k]?.name_de || k}
</option>
))}
</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>
<select
className="form-input"
value={catAdd.training_parameter_id}
onChange={(e) => setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
>
<option value=""> wählen </option>
{activeParams.map((p) => (
<option key={p.id} value={p.id}>
{p.id} · {p.key} ({p.name_de})
</option>
))}
</select>
</div>
<div>
<label className="form-label">sort</label>
<input
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 }}>
<input
type="checkbox"
checked={catAdd.required}
onChange={(e) => setCatAdd((a) => ({ ...a, required: e.target.checked }))}
/>
Pflicht
</label>
<div>
<label className="form-label">ui_group</label>
<input
className="form-input"
style={{ width: 120 }}
value={catAdd.ui_group}
onChange={(e) => setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
</div>
<button type="button" className="btn btn-primary" onClick={addCatLink}>
<Plus size={14} /> Hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
{catLinks.map((l) => (
<li
key={l.id}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
{editingCatId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, 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>
<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 }}>
<input
type="checkbox"
checked={!!catDraft.required}
onChange={(e) => setCatDraft((d) => ({ ...d, required: e.target.checked }))}
/>
Pflicht
</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 }))}
/>
<button type="button" className="btn btn-primary" onClick={() => saveCatLink(l.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditingCatId(null)}>
Abbrechen
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span>
<strong>{l.parameter_key}</strong> · {l.parameter_name_de} · sort {l.sort_order}
{l.required ? ' · Pflicht' : ''}
{l.ui_group ? ` · ${l.ui_group}` : ''}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => {
setEditingCatId(l.id)
setCatDraft({
sort_order: l.sort_order,
required: !!l.required,
ui_group: l.ui_group || '',
})
}}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={async () => {
if (!confirm('Zuordnung entfernen?')) return
await api.adminDeleteTrainingCategoryParameter(l.id)
await loadCatLinks()
showToast('Entfernt')
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</li>
))}
</ul>
</div>
)}
{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>
<select
className="form-input"
value={selTypeId}
onChange={(e) => setSelTypeId(e.target.value)}
style={{ maxWidth: 420 }}
>
{flatTypes.map((t) => (
<option key={t.id} value={t.id}>
{t.id} · {t.name_de} ({t.category})
</option>
))}
</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>
<select
className="form-input"
value={typeAdd.training_parameter_id}
onChange={(e) => setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
>
<option value=""> wählen </option>
{activeParams.map((p) => (
<option key={p.id} value={p.id}>
{p.id} · {p.key}
</option>
))}
</select>
</div>
<div>
<label className="form-label">sort (leer=Erben)</label>
<input
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>
<select
className="form-input"
style={{ width: 100 }}
value={typeAdd.required}
onChange={(e) => setTypeAdd((a) => ({ ...a, required: e.target.value }))}
>
<option value=""></option>
<option value="true">ja</option>
<option value="false">nein</option>
</select>
</div>
<div>
<label className="form-label">ui_group</label>
<input
className="form-input"
style={{ width: 120 }}
value={typeAdd.ui_group}
onChange={(e) => setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
/>
</div>
<button type="button" className="btn btn-primary" onClick={addTypeLink}>
<Plus size={14} /> Hinzufügen
</button>
</div>
<ul style={{ listStyle: 'none', padding: 0, marginTop: 12 }}>
{typeLinks.map((l) => (
<li
key={l.id}
style={{
padding: '10px 0',
borderBottom: '1px solid var(--border)',
fontSize: 13,
}}
>
{editingTypeId === l.id ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'flex-end' }}>
<span style={{ flex: '1 1 200px' }}>
<strong>{l.parameter_key}</strong>
</span>
<div>
<label className="form-label">sort</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 }))}
>
<option value="">Erben</option>
<option value="true">ja</option>
<option value="false">nein</option>
</select>
<input
className="form-input"
style={{ width: 100 }}
placeholder="ui_group"
value={typeDraft.ui_group}
onChange={(e) => setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))}
/>
<button type="button" className="btn btn-primary" onClick={() => saveTypeLink(l.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditingTypeId(null)}>
Abbrechen
</button>
</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<span>
<strong>{l.parameter_key}</strong> · sort {l.sort_order ?? '—'} · Pflicht{' '}
{l.required === null || l.required === undefined
? '—'
: l.required
? 'ja'
: 'nein'}
</span>
<div style={{ display: 'flex', gap: 4 }}>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '4px 8px' }}
onClick={() => {
setEditingTypeId(l.id)
setTypeDraft({
sort_order: l.sort_order ?? '',
required:
l.required === null || l.required === undefined
? ''
: l.required
? 'true'
: 'false',
ui_group: l.ui_group || '',
})
}}
>
<Pencil size={14} />
</button>
<button
type="button"
className="btn btn-danger"
style={{ padding: '4px 8px' }}
onClick={async () => {
if (!confirm('Zuordnung entfernen?')) return
await api.adminDeleteTrainingTypeParameter(l.id)
await loadTypeLinks()
showToast('Entfernt')
}}
>
<Trash2 size={14} />
</button>
</div>
</div>
)}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@ -297,10 +297,19 @@ export default function AdminCsvTemplateEditorPage() {
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
const targetOptions = useMemo(() => {
if (!modMeta?.fields || aggregateSleepImport) return []
return Object.entries(modMeta.fields).map(([key, meta]) => ({
value: key,
label: `${key}${meta.required ? ' *' : ''}`,
}))
const entries = Object.entries(modMeta.fields).map(([key, meta]) => {
const title = meta.label_de || meta.name_de || key
return {
value: key,
label: `${title}${meta.required ? ' *' : ''}`,
group: meta.from_training_parameter ? 'eav' : 'log',
}
})
entries.sort((a, b) => {
if (a.group !== b.group) return a.group === 'log' ? -1 : 1
return a.label.localeCompare(b.label, 'de')
})
return entries
}, [modMeta, aggregateSleepImport])
const requiredTargets = useMemo(() => {
@ -1025,11 +1034,23 @@ export default function AdminCsvTemplateEditorPage() {
}}
>
<option value="-"> ignorieren</option>
{targetOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
{['log', 'eav'].map((g) => {
const opts = targetOptions.filter((o) => o.group === g)
if (!opts.length) return null
const ogLabel =
g === 'log'
? 'Activity — Kernfelder (activity_log)'
: 'Trainingsparameter (EAV)'
return (
<optgroup key={g} label={ogLabel}>
{opts.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
)
})}
</select>
</div>
))}

View File

@ -138,16 +138,30 @@ export const api = {
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
// Activity
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert */
listActivity: (limit=200, days)=> {
/**
* @param {number} [limit=200]
* @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert
* @param {{ offset?: number, skipQualityFilter?: boolean, month?: string, collapseDuplicateSessions?: boolean }} [opts] month = YYYY-MM (schließt days/offset aus)
*/
listActivity: (limit=200, days, opts={})=> {
const q = new URLSearchParams({ limit: String(limit) })
if (days != null && days !== '') q.set('days', String(days))
if (opts.month) q.set('month', String(opts.month))
if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset))
if (opts.collapseDuplicateSessions) q.set('collapse_duplicate_sessions', 'true')
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
return req(`/activity?${q}`)
},
createActivity: (d) => req('/activity',json(d)),
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
activityStats: () => req('/activity/stats'),
/** @param {{ skipQualityFilter?: boolean }} [opts] */
activityStats: (opts={}) => {
const q = new URLSearchParams()
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
const qs = q.toString()
return req(`/activity/stats${qs ? `?${qs}` : ''}`)
},
listUncategorizedActivities: () => req('/activity/uncategorized'),
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
importActivityCsv: async(file)=>{
@ -314,6 +328,34 @@ export const api = {
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
// Admin: Training session metrics (EAV) & attribute profiles (Migration 054)
adminListTrainingParameters: (includeInactive = false) =>
req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`),
adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)),
adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)),
adminDeleteTrainingParameter: (id) =>
req(`/admin/training-parameters/${id}`, { method: 'DELETE' }),
adminListTrainingCategoryParameters: (category = '') =>
req(
`/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
),
adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
adminUpdateTrainingCategoryParameter: (id, d) =>
req(`/admin/training-category-parameters/${id}`, jput(d)),
adminDeleteTrainingCategoryParameter: (id) =>
req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
adminListTrainingTypeParameters: (trainingTypeId) =>
req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
adminUpdateTrainingTypeParameter: (id, d) =>
req(`/admin/training-type-parameters/${id}`, jput(d)),
adminDeleteTrainingTypeParameter: (id) =>
req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
putActivityMetrics: (id, body) =>
req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
// Sleep Module (v9d Phase 2b)
listSleep: (l=90) => req(`/sleep?limit=${l}`),
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),

View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Volles PostgreSQL-Backup im Custom-Format (pg_restore-kompatibel).
# Auf dem Host ausführen, wo der Postgres-Container läuft (z. B. Raspberry Pi mit docker compose).
#
# BACKUP_DIR=/path/to/safe/storage ./scripts/backup/mitai_pg_dump.sh
#
# Variablen (optional): POSTGRES_CONTAINER, POSTGRES_DB, POSTGRES_USER, BACKUP_DIR
set -euo pipefail
CONTAINER="${POSTGRES_CONTAINER:-mitai-db-prod}"
DB="${POSTGRES_DB:-mitai_prod}"
USER="${POSTGRES_USER:-mitai_prod}"
OUT_DIR="${BACKUP_DIR:-.}"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p "${OUT_DIR}"
OUT="${OUT_DIR}/${DB}_${STAMP}.dump"
if ! docker inspect "$CONTAINER" &>/dev/null; then
echo "Container nicht gefunden: $CONTAINER" >&2
exit 1
fi
docker exec "$CONTAINER" pg_dump -U "$USER" -Fc --no-owner --no-acl "$DB" > "$OUT"
ls -la "$OUT"
echo "OK: $OUT (zum Zurückspielen: siehe Kommentar unten in diesem Skript)"
# ── Restore (nur bei Notfall; Backend vorher stoppen, sonst offene Verbindungen) ──
# docker compose stop backend
# docker cp "$OUT" "$CONTAINER:/tmp/restore.dump"
# docker exec "$CONTAINER" pg_restore -U "$USER" -d "$DB" --clean --if-exists --no-owner --no-acl /tmp/restore.dump
# docker compose start backend
#
# Hinweis: --clean entfernt Objekte vor dem Wiederherstellen; kurze Unterbrechung der DB.
# Für „nur lesen“ Backup reicht die .dump-Datei auf externem Medium zu kopieren.