From e7dedd527f5eaac3634b24a8d1464a7c7dfd6da2 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Apr 2026 07:28:19 +0200 Subject: [PATCH 01/27] feat: Implement focus area usage types management in API and UI - Added endpoints for listing and updating focus area usage types in the backend. - Enhanced the AdminFocusAreasPage to display and manage allowed usage types for focus areas. - Introduced a new state for usage types catalog and integrated it into the focus area editing process. - Updated API utility functions to support new usage types operations. --- backend/focus_area_usage_helpers.py | 20 +++ .../migrations/036_focus_area_usage_types.sql | 36 +++++ backend/routers/focus_areas.py | 115 +++++++++++++- backend/tests/test_focus_area_usage_types.py | 127 +++++++++++++++ frontend/src/pages/AdminFocusAreasPage.jsx | 150 ++++++++++++++++-- frontend/src/utils/api.js | 3 + 6 files changed, 435 insertions(+), 16 deletions(-) create mode 100644 backend/focus_area_usage_helpers.py create mode 100644 backend/migrations/036_focus_area_usage_types.sql create mode 100644 backend/tests/test_focus_area_usage_types.py diff --git a/backend/focus_area_usage_helpers.py b/backend/focus_area_usage_helpers.py new file mode 100644 index 0000000..52d5695 --- /dev/null +++ b/backend/focus_area_usage_helpers.py @@ -0,0 +1,20 @@ +"""Kleine Helfer für Focus-Area-Nutzungstypen (ohne Router-/Auth-Abhängigkeiten).""" +from __future__ import annotations + +import json +from typing import Any, List + + +def coerce_usage_type_keys(raw: Any) -> List[str]: + """json_agg / JSON-Spalte zu list[str] normalisieren.""" + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw] + if isinstance(raw, str): + try: + data = json.loads(raw) + return [str(x) for x in data] if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + return [] diff --git a/backend/migrations/036_focus_area_usage_types.sql b/backend/migrations/036_focus_area_usage_types.sql new file mode 100644 index 0000000..566fa68 --- /dev/null +++ b/backend/migrations/036_focus_area_usage_types.sql @@ -0,0 +1,36 @@ +-- Migration 036: Focus Area — erlaubte Nutzungstypen (Referenz + M:N) +-- Date: 2026-04-06 +-- Purpose: System-seeded usage types; optional Zuordnung pro Focus Area (kein Auto-Backfill) + +-- Referenztabelle: feste, systemdefinierte Nutzungstypen +CREATE TABLE IF NOT EXISTS focus_area_usage_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(64) UNIQUE NOT NULL, + label_de VARCHAR(160), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE focus_area_usage_types IS + 'Systemdefinierte Nutzungsarten für Focus Areas (kein Admin-CRUD in v1)'; + +-- M:N: welche Nutzungstypen für eine Focus Area erlaubt sind (leer = noch nicht klassifiziert) +CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types ( + focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE, + usage_type_id UUID NOT NULL REFERENCES focus_area_usage_types(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (focus_area_id, usage_type_id) +); + +CREATE INDEX IF NOT EXISTS idx_fadut_focus_area ON focus_area_definition_usage_types(focus_area_id); +CREATE INDEX IF NOT EXISTS idx_fadut_usage_type ON focus_area_definition_usage_types(usage_type_id); + +COMMENT ON TABLE focus_area_definition_usage_types IS + 'Zuordnung Focus Area → erlaubte Nutzungstypen (kein automatisches Befüllen bestehender Areas)'; + +-- Seed: nur die drei Typen — keine Zeilen in der Junction-Tabelle +INSERT INTO focus_area_usage_types (key, label_de, sort_order) VALUES + ('goal_priority', 'Ziele / Prioritäten', 1), + ('expected_training_effect', 'Erwartetes Trainingseffekt-Profil', 2), + ('concrete_training_contribution', 'Konkrete Trainings-Beiträge / Belastungsausprägung', 3) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py index d41d6b0..cfc70b4 100644 --- a/backend/routers/focus_areas.py +++ b/backend/routers/focus_areas.py @@ -3,10 +3,11 @@ Focus Areas Router Manages dynamic focus area definitions and user preferences """ from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from db import get_db, get_cursor, r2d from auth import require_auth +from focus_area_usage_helpers import coerce_usage_type_keys router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"]) @@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel): """User's focus area weightings (dynamic)""" preferences: dict # {focus_area_id: weight_pct} + +class FocusAreaUsageTypesUpdate(BaseModel): + """Replace all usage-type assignments for one focus area (admin).""" + usage_type_keys: List[str] = Field(default_factory=list) + # ============================================================================ # Focus Area Definitions (Admin) # ============================================================================ @@ -58,14 +64,27 @@ def list_focus_area_definitions( query = """ SELECT id, key, name_de, name_en, icon, description, category, is_active, - created_at, updated_at + created_at, updated_at, + COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = focus_area_definitions.id + ), + '[]'::json + ) AS allowed_usage_type_keys FROM focus_area_definitions WHERE is_active = true OR %s ORDER BY category, name_de """ cur.execute(query, (include_inactive,)) - areas = [r2d(row) for row in cur.fetchall()] + areas = [] + for row in cur.fetchall(): + d = r2d(row) + d['allowed_usage_type_keys'] = coerce_usage_type_keys(d.get('allowed_usage_type_keys')) + areas.append(d) # Group by category grouped = {} @@ -75,6 +94,10 @@ def list_focus_area_definitions( grouped[cat] = [] grouped[cat].append(area) + if session.get('role') != 'admin': + for area in areas: + area.pop('allowed_usage_type_keys', None) + return { "areas": areas, "grouped": grouped, @@ -226,6 +249,92 @@ def delete_focus_area_definition( return {"message": "Focus Area gelöscht"} + +@router.get("/usage-types") +def list_focus_area_usage_types(session: dict = Depends(require_auth)): + """ + Liste aller systemdefinierten Nutzungstypen (Admin, Konfigurations-UI). + Keine freie Anlage neuer Typen über die API. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, key, label_de, sort_order + FROM focus_area_usage_types + ORDER BY sort_order, key + """) + rows = [r2d(r) for r in cur.fetchall()] + return {"usage_types": rows, "total": len(rows)} + + +@router.put("/definitions/{area_id}/usage-types") +def replace_focus_area_usage_types( + area_id: str, + data: FocusAreaUsageTypesUpdate, + session: dict = Depends(require_auth), +): + """ + Ersetzt die Nutzungstyp-Zuweisungen einer Focus Area (Admin). + Leere Liste entfernt alle Zuordnungen. Unbekannte Keys → 400. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + keys = list(dict.fromkeys(data.usage_type_keys)) # dedupe, preserve order + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + "SELECT id FROM focus_area_definitions WHERE id = %s", + (area_id,), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Focus Area nicht gefunden") + + if not keys: + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + return {"message": "Nutzungstyp-Zuweisungen entfernt", "usage_type_keys": []} + + placeholders = ','.join(['%s'] * len(keys)) + cur.execute( + f""" + SELECT id, key FROM focus_area_usage_types + WHERE key IN ({placeholders}) + """, + keys, + ) + found = {row['key']: row['id'] for row in cur.fetchall()} + missing = [k for k in keys if k not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Nutzungstyp-Keys: {', '.join(missing)}", + ) + + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + for k in keys: + ut_id = found[k] + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + VALUES (%s, %s) + ON CONFLICT (focus_area_id, usage_type_id) DO NOTHING + """, + (area_id, ut_id), + ) + + return {"message": "Nutzungstyp-Zuweisungen aktualisiert", "usage_type_keys": keys} + # ============================================================================ # User Focus Preferences # ============================================================================ diff --git a/backend/tests/test_focus_area_usage_types.py b/backend/tests/test_focus_area_usage_types.py new file mode 100644 index 0000000..3340770 --- /dev/null +++ b/backend/tests/test_focus_area_usage_types.py @@ -0,0 +1,127 @@ +""" +Tests: Focus Area Nutzungstypen (Migration 036, Router-Helfer). + +Ohne MITAI_INTEGRATION_DB=1 werden nur SQL-Datei und reine Python-Helfer geprüft. +Mit gesetztem Flag optional Verifikation gegen eine PostgreSQL-Instanz (Migration 036 angewendet). +""" +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_ROOT)) + + +def test_migration_036_defines_schema_and_seeds_keys(): + p = BACKEND_ROOT / "migrations" / "036_focus_area_usage_types.sql" + assert p.is_file(), f"expected {p}" + text = p.read_text(encoding="utf-8") + assert "CREATE TABLE IF NOT EXISTS focus_area_usage_types" in text + assert "CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types" in text + for key in ( + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ): + assert key in text + assert "ON CONFLICT (key) DO NOTHING" in text + # Explizit: kein automatisches Befüllen der M:N-Tabelle + assert "INSERT INTO focus_area_definition_usage_types" not in text + + +def test_coerce_usage_type_keys_normalizes_values(): + from focus_area_usage_helpers import coerce_usage_type_keys + + assert coerce_usage_type_keys(None) == [] + assert coerce_usage_type_keys([]) == [] + assert coerce_usage_type_keys(["goal_priority", "expected_training_effect"]) == [ + "goal_priority", + "expected_training_effect", + ] + assert coerce_usage_type_keys('["concrete_training_contribution"]') == [ + "concrete_training_contribution" + ] + + +@pytest.mark.skipif( + os.getenv("MITAI_INTEGRATION_DB") != "1", + reason="Set MITAI_INTEGRATION_DB=1 plus DB_* env to run DB checks (nur Dev/CI!)", +) +def test_integration_focus_area_usage_types_seeded_and_junction_writable(): + """Nur gegen Dev-DB ausführen. Nutzt temporäre focus_area_definitions-Zeile, keine bestehenden Daten.""" + from db import get_db, get_cursor + from focus_area_usage_helpers import coerce_usage_type_keys + + tmp_id = str(uuid.uuid4()) + tmp_key = f"tmp_usage_{tmp_id[:8]}" + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT key FROM focus_area_usage_types + ORDER BY sort_order, key + """ + ) + keys = [row["key"] for row in cur.fetchall()] + assert keys == [ + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ] + + cur.execute( + """ + INSERT INTO focus_area_definitions + (id, key, name_de, category, is_active) + VALUES (%s, %s, 'tmp_test_usage', 'custom', false) + """, + (tmp_id, tmp_key), + ) + + cur.execute( + """ + SELECT COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = %s + ), + '[]'::json + ) AS allowed_usage_type_keys + """, + (tmp_id,), + ) + assert coerce_usage_type_keys(cur.fetchone()["allowed_usage_type_keys"]) == [] + + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "goal_priority"), + ) + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "expected_training_effect"), + ) + cur.execute( + """ + SELECT COUNT(*) AS n + FROM focus_area_definition_usage_types + WHERE focus_area_id = %s + """, + (tmp_id,), + ) + assert cur.fetchone()["n"] == 2 + + cur.execute("DELETE FROM focus_area_definitions WHERE id = %s", (tmp_id,)) diff --git a/frontend/src/pages/AdminFocusAreasPage.jsx b/frontend/src/pages/AdminFocusAreasPage.jsx index f28df7e..a68c462 100644 --- a/frontend/src/pages/AdminFocusAreasPage.jsx +++ b/frontend/src/pages/AdminFocusAreasPage.jsx @@ -14,8 +14,19 @@ const CATEGORIES = [ { value: 'custom', label: 'Eigene' } ] +function groupAreasByCategory(areas) { + const grouped = {} + for (const area of areas) { + const cat = area.category || 'other' + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(area) + } + return grouped +} + export default function AdminFocusAreasPage() { const [data, setData] = useState({ areas: [], grouped: {}, total: 0 }) + const [usageTypesCatalog, setUsageTypesCatalog] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showInactive, setShowInactive] = useState(false) @@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() { loadData() }, [showInactive]) + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const r = await api.listFocusAreaUsageTypes() + if (!cancelled) setUsageTypesCatalog(r.usage_types || []) + } catch (e) { + console.error('usage-types catalog:', e) + if (!cancelled) setUsageTypesCatalog([]) + } + })() + return () => { cancelled = true } + }, []) + const loadData = async () => { try { setLoading(true) const result = await api.listFocusAreaDefinitions(showInactive) - setData(result) + const areas = (result.areas || []).map(a => ({ + ...a, + allowed_usage_type_keys: Array.isArray(a.allowed_usage_type_keys) + ? a.allowed_usage_type_keys + : [] + })) + setData({ + areas, + grouped: groupAreasByCategory(areas), + total: result.total ?? areas.length + }) setError(null) } catch (err) { console.error('Failed to load focus areas:', err) @@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() { const handleUpdate = async (id) => { try { const area = data.areas.find(a => a.id === id) - await api.updateFocusAreaDefinition(id, { - name_de: area.name_de, - name_en: area.name_en, - icon: area.icon, - description: area.description, - category: area.category, - is_active: area.is_active - }) + const usageKeys = Array.isArray(area.allowed_usage_type_keys) + ? area.allowed_usage_type_keys + : [] + await Promise.all([ + api.updateFocusAreaDefinition(id, { + name_de: area.name_de, + name_en: area.name_en, + icon: area.icon, + description: area.description, + category: area.category, + is_active: area.is_active + }), + api.setFocusAreaUsageTypes(id, usageKeys) + ]) setEditingId(null) await loadData() } catch (err) { @@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() { } const updateField = (id, field, value) => { - setData(prev => ({ - ...prev, - areas: prev.areas.map(a => + setData(prev => { + const areas = prev.areas.map(a => a.id === id ? { ...a, [field]: value } : a ) - })) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) + } + + const toggleUsageTypeKey = (areaId, key, checked) => { + setData(prev => { + const areas = prev.areas.map(a => { + if (a.id !== areaId) return a + const cur = new Set(Array.isArray(a.allowed_usage_type_keys) ? a.allowed_usage_type_keys : []) + if (checked) cur.add(key) + else cur.delete(key) + return { ...a, allowed_usage_type_keys: [...cur] } + }) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) } if (loading) { @@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() { /> +
+ + {usageTypesCatalog.length === 0 ? ( + + Kein Katalog geladen (Backend / Migration prüfen). + + ) : ( +
+ {usageTypesCatalog.map(ut => ( + + ))} +
+ )} +
+
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 73dee1e..62263d9 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -375,6 +375,9 @@ export const api = { getUserFocusPreferences: () => req('/focus-areas/user-preferences'), updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)), getFocusAreaStats: () => req('/focus-areas/stats'), + listFocusAreaUsageTypes: () => req('/focus-areas/usage-types'), + setFocusAreaUsageTypes: (id, usageTypeKeys) => + req(`/focus-areas/definitions/${id}/usage-types`, jput({ usage_type_keys: usageTypeKeys })), // Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery) // Nutrition Charts (E1-E5) From f0e6fd04fb2af033570b76ad8cc28167dd52fd14 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 6 Apr 2026 19:45:06 +0200 Subject: [PATCH 02/27] feat: Add personal reference values management in settings and API - Introduced new routes and API endpoints for managing personal reference values. - Updated the SettingsPage to include a section for reference values with navigation to manage them. - Enhanced the backend to support reference values in the data layer and versioning. - Added necessary imports and UI components for a seamless user experience. --- backend/data_layer/__init__.py | 2 + .../data_layer/training_profile/__init__.py | 36 ++ .../training_profile/algorithms/__init__.py | 13 + .../training_profile/algorithms/base.py | 23 ++ .../algorithms/builtin/__init__.py | 6 + .../algorithms/builtin/linear_range.py | 69 ++++ .../algorithms/builtin/threshold_band.py | 75 ++++ .../training_profile/algorithms/registry.py | 47 +++ backend/data_layer/training_profile/models.py | 121 ++++++ .../training_profile/profiles/__init__.py | 13 + .../training_profile/profiles/registry.py | 52 +++ .../data_layer/training_profile/resolver.py | 160 ++++++++ .../training_profile/templates/__init__.py | 5 + .../training_profile/templates/registry.py | 96 +++++ backend/main.py | 2 + .../037_profile_reference_values.sql | 99 +++++ backend/routers/reference_values.py | 355 +++++++++++++++++ .../tests/test_training_profile_resolver.py | 138 +++++++ backend/version.py | 3 +- frontend/src/App.jsx | 2 + .../src/pages/ProfileReferenceValuesPage.jsx | 377 ++++++++++++++++++ frontend/src/pages/SettingsPage.jsx | 19 +- frontend/src/utils/api.js | 8 + 23 files changed, 1719 insertions(+), 2 deletions(-) create mode 100644 backend/data_layer/training_profile/__init__.py create mode 100644 backend/data_layer/training_profile/algorithms/__init__.py create mode 100644 backend/data_layer/training_profile/algorithms/base.py create mode 100644 backend/data_layer/training_profile/algorithms/builtin/__init__.py create mode 100644 backend/data_layer/training_profile/algorithms/builtin/linear_range.py create mode 100644 backend/data_layer/training_profile/algorithms/builtin/threshold_band.py create mode 100644 backend/data_layer/training_profile/algorithms/registry.py create mode 100644 backend/data_layer/training_profile/models.py create mode 100644 backend/data_layer/training_profile/profiles/__init__.py create mode 100644 backend/data_layer/training_profile/profiles/registry.py create mode 100644 backend/data_layer/training_profile/resolver.py create mode 100644 backend/data_layer/training_profile/templates/__init__.py create mode 100644 backend/data_layer/training_profile/templates/registry.py create mode 100644 backend/migrations/037_profile_reference_values.sql create mode 100644 backend/routers/reference_values.py create mode 100644 backend/tests/test_training_profile_resolver.py create mode 100644 frontend/src/pages/ProfileReferenceValuesPage.jsx diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index 2742cde..2633540 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -19,6 +19,8 @@ Modules: - goals: Active goals, progress, projections - correlations: Lag-analysis, plateau detection - utils: Shared functions (confidence, baseline, outliers) + - training_profile: Template-based training evaluation scaffold (Layer 1) + Import: ``from data_layer.training_profile import resolve_training_evaluation`` Phase 0c: Multi-Layer Architecture Version: 1.0 diff --git a/backend/data_layer/training_profile/__init__.py b/backend/data_layer/training_profile/__init__.py new file mode 100644 index 0000000..913090f --- /dev/null +++ b/backend/data_layer/training_profile/__init__.py @@ -0,0 +1,36 @@ +""" +Training profile resolver (Layer 1 scaffold). + +Template-driven multi-dimensional evaluation with built-in algorithms and +Focus Area contribution aggregation. Import explicitly from this package. + +Public API: + - resolve_training_evaluation + - resolve_for_base_profile + - models: CalculationTemplate, TrainingEvaluationResult, ... + - registries: templates, profiles, algorithms +""" + +from data_layer.training_profile.models import ( + CalculationTemplate, + DimensionResult, + DimensionSpec, + FocusAreaMapping, + TrainingBaseProfile, + TrainingEvaluationResult, +) +from data_layer.training_profile.resolver import ( + resolve_for_base_profile, + resolve_training_evaluation, +) + +__all__ = [ + "CalculationTemplate", + "DimensionResult", + "DimensionSpec", + "FocusAreaMapping", + "TrainingBaseProfile", + "TrainingEvaluationResult", + "resolve_for_base_profile", + "resolve_training_evaluation", +] diff --git a/backend/data_layer/training_profile/algorithms/__init__.py b/backend/data_layer/training_profile/algorithms/__init__.py new file mode 100644 index 0000000..2748a00 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/__init__.py @@ -0,0 +1,13 @@ +"""Built-in training evaluation algorithms (code-defined only).""" + +from .registry import ( + get_algorithm, + list_algorithm_ids, + register_algorithm, +) + +__all__ = [ + "get_algorithm", + "list_algorithm_ids", + "register_algorithm", +] diff --git a/backend/data_layer/training_profile/algorithms/base.py b/backend/data_layer/training_profile/algorithms/base.py new file mode 100644 index 0000000..41c9b0d --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/base.py @@ -0,0 +1,23 @@ +""" +Algorithm protocol: fixed implementations selected by id from declarative templates. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Protocol + +from data_layer.training_profile.models import AlgorithmRunResult + + +class TrainingAlgorithm(Protocol): + """Built-in algorithm callable shape.""" + + id: str + + def __call__( + self, + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], + ) -> AlgorithmRunResult: + ... diff --git a/backend/data_layer/training_profile/algorithms/builtin/__init__.py b/backend/data_layer/training_profile/algorithms/builtin/__init__.py new file mode 100644 index 0000000..af52967 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/__init__.py @@ -0,0 +1,6 @@ +"""Example built-in algorithms (threshold bands, linear range mapping).""" + +from .linear_range import linear_range_score +from .threshold_band import threshold_band_score + +__all__ = ["linear_range_score", "threshold_band_score"] diff --git a/backend/data_layer/training_profile/algorithms/builtin/linear_range.py b/backend/data_layer/training_profile/algorithms/builtin/linear_range.py new file mode 100644 index 0000000..c0186a0 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/linear_range.py @@ -0,0 +1,69 @@ +""" +linear_range: map a value linearly from [min_value, max_value] to [0, 1], clamped. + +params: + value_key: str + min_value: float + max_value: float + invert: bool (optional) — if True, high values map to 0 + +Missing value → score 0 and missing_inputs. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.utils import safe_float + + +ALGORITHM_ID = "linear_range" + + +def linear_range_score( + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], +) -> AlgorithmRunResult: + value_key = str(params.get("value_key", "")) + if not value_key: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=["__param_value_key__"], + detail={"error": "params.value_key required"}, + ) + + if value_key not in inputs or inputs[value_key] is None: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=[value_key], + detail={"value_key": value_key}, + ) + + raw = safe_float(inputs[value_key]) + lo = safe_float(params.get("min_value")) + hi = safe_float(params.get("max_value")) + invert = bool(params.get("invert", False)) + + if hi <= lo: + return AlgorithmRunResult( + raw_score=raw, + normalized_score=0.0, + missing_inputs=[], + detail={"error": "max_value must be > min_value", "min": lo, "max": hi}, + ) + + t = (raw - lo) / (hi - lo) + t = max(0.0, min(1.0, t)) + if invert: + t = 1.0 - t + + return AlgorithmRunResult( + raw_score=raw, + normalized_score=t, + missing_inputs=[], + detail={"value_key": value_key, "min": lo, "max": hi, "invert": invert}, + ) diff --git a/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py b/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py new file mode 100644 index 0000000..4659e8f --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py @@ -0,0 +1,75 @@ +""" +threshold_band: map a numeric value into a score using ordered upper bounds. + +params: + value_key: str — key in inputs to read + bands: list of { "max": float | null, "score": float } — ascending max; null = +inf + +Missing value_key → normalized_score 0, missing_inputs lists value_key. +""" + +from __future__ import annotations + +from typing import Any, List, Mapping + +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.utils import safe_float + + +ALGORITHM_ID = "threshold_band" + + +def threshold_band_score( + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], +) -> AlgorithmRunResult: + value_key = str(params.get("value_key", "")) + if not value_key: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=["__param_value_key__"], + detail={"error": "params.value_key required"}, + ) + + if value_key not in inputs or inputs[value_key] is None: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=[value_key], + detail={"value_key": value_key}, + ) + + raw = safe_float(inputs[value_key]) + bands = params.get("bands") or [] + if not isinstance(bands, list) or not bands: + return AlgorithmRunResult( + raw_score=raw, + normalized_score=0.0, + missing_inputs=[], + detail={"error": "params.bands must be a non-empty list", "raw": raw}, + ) + + score = 0.0 + for band in bands: + if not isinstance(band, dict): + continue + max_v = band.get("max") + s = safe_float(band.get("score")) + if max_v is None: + score = s + break + if raw <= safe_float(max_v): + score = s + break + else: + score = safe_float(bands[-1].get("score")) if isinstance(bands[-1], dict) else 0.0 + + norm = max(0.0, min(1.0, score)) + return AlgorithmRunResult( + raw_score=raw, + normalized_score=norm, + missing_inputs=[], + detail={"value_key": value_key, "bands_applied": True, "band_score": score}, + ) diff --git a/backend/data_layer/training_profile/algorithms/registry.py b/backend/data_layer/training_profile/algorithms/registry.py new file mode 100644 index 0000000..052e472 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/registry.py @@ -0,0 +1,47 @@ +""" +Registry for built-in algorithm ids → callables. + +Only code registers algorithms; templates reference ids declared here. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Mapping + +from data_layer.training_profile.algorithms.builtin.linear_range import ( + ALGORITHM_ID as LINEAR_RANGE_ID, + linear_range_score, +) +from data_layer.training_profile.algorithms.builtin.threshold_band import ( + ALGORITHM_ID as THRESHOLD_BAND_ID, + threshold_band_score, +) +from data_layer.training_profile.models import AlgorithmRunResult + +AlgorithmFn = Callable[..., AlgorithmRunResult] + +_REGISTRY: Dict[str, AlgorithmFn] = {} + + +def register_algorithm(algorithm_id: str, fn: AlgorithmFn) -> None: + if algorithm_id in _REGISTRY: + raise ValueError(f"Algorithm already registered: {algorithm_id}") + _REGISTRY[algorithm_id] = fn + + +def get_algorithm(algorithm_id: str) -> AlgorithmFn: + if algorithm_id not in _REGISTRY: + raise KeyError(f"Unknown algorithm_id: {algorithm_id}") + return _REGISTRY[algorithm_id] + + +def list_algorithm_ids() -> tuple[str, ...]: + return tuple(sorted(_REGISTRY.keys())) + + +def _register_defaults() -> None: + register_algorithm(THRESHOLD_BAND_ID, threshold_band_score) + register_algorithm(LINEAR_RANGE_ID, linear_range_score) + + +_register_defaults() diff --git a/backend/data_layer/training_profile/models.py b/backend/data_layer/training_profile/models.py new file mode 100644 index 0000000..2d4c58b --- /dev/null +++ b/backend/data_layer/training_profile/models.py @@ -0,0 +1,121 @@ +""" +Declarative schemas for template-based training evaluation (Layer 1 scaffold). + +Templates select built-in algorithms by id and pass parameters; they do not embed +executable logic. See resolver and algorithm registry. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + + +@dataclass(frozen=True) +class FocusAreaMapping: + """Maps a share of one dimension's score to a Focus Area (by stable key).""" + + focus_area_key: str + weight: float + + +@dataclass(frozen=True) +class DimensionSpec: + """ + One evaluation dimension: algorithm + inputs + params + FA mapping. + + inputs: names of keys expected in the flat activity_inputs dict passed to the resolver. + """ + + key: str + algorithm_id: str + inputs: tuple[str, ...] + params: Mapping[str, Any] + maps_to: tuple[FocusAreaMapping, ...] + + +@dataclass(frozen=True) +class CalculationTemplate: + """Declarative multi-dimensional calculation template (in-code registry).""" + + id: str + version: str + label: str + dimensions: tuple[DimensionSpec, ...] + + +@dataclass(frozen=True) +class TrainingBaseProfile: + """ + Conceptual base profile: links a training context to a default template. + + allowed_dimension_keys: if set, dimensions not listed are skipped at resolve time. + """ + + key: str + label: str + default_template_id: str + allowed_dimension_keys: Optional[frozenset[str]] = None + + +@dataclass +class AlgorithmRunResult: + """Output of a single built-in algorithm execution.""" + + raw_score: float + normalized_score: float + missing_inputs: List[str] + detail: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DimensionResult: + """Per-dimension result after resolution.""" + + dimension_key: str + algorithm_id: str + raw_score: float + normalized_score: float + missing_inputs: List[str] + evidence: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class TrainingEvaluationResult: + """ + Stable structured result for Layer 2 (KI, charts, APIs, future persistence). + + focus_area_contributions: aggregated contribution per focus_area key (additive). + """ + + template_id: str + template_version: str + base_profile_key: Optional[str] + dimension_results: List[DimensionResult] + focus_area_contributions: Dict[str, float] + confidence: str + evidence: Dict[str, Any] + trace: Optional[Dict[str, Any]] = None + + def to_serializable(self) -> Dict[str, Any]: + """JSON-compatible dict (for APIs / storage).""" + return { + "template_id": self.template_id, + "template_version": self.template_version, + "base_profile_key": self.base_profile_key, + "dimension_results": [ + { + "dimension_key": d.dimension_key, + "algorithm_id": d.algorithm_id, + "raw_score": d.raw_score, + "normalized_score": d.normalized_score, + "missing_inputs": d.missing_inputs, + "evidence": d.evidence, + } + for d in self.dimension_results + ], + "focus_area_contributions": dict(self.focus_area_contributions), + "confidence": self.confidence, + "evidence": self.evidence, + "trace": self.trace, + } diff --git a/backend/data_layer/training_profile/profiles/__init__.py b/backend/data_layer/training_profile/profiles/__init__.py new file mode 100644 index 0000000..ee61213 --- /dev/null +++ b/backend/data_layer/training_profile/profiles/__init__.py @@ -0,0 +1,13 @@ +"""Training base profile registrations (scaffold, in-code).""" + +from .registry import ( + get_training_base_profile, + list_training_base_profile_keys, + try_get_training_base_profile, +) + +__all__ = [ + "get_training_base_profile", + "list_training_base_profile_keys", + "try_get_training_base_profile", +] diff --git a/backend/data_layer/training_profile/profiles/registry.py b/backend/data_layer/training_profile/profiles/registry.py new file mode 100644 index 0000000..269212e --- /dev/null +++ b/backend/data_layer/training_profile/profiles/registry.py @@ -0,0 +1,52 @@ +""" +Example training base profiles — point to default templates and optional dimension filters. + +Future: DB-backed training types may reference profile keys; this registry is code-only. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from data_layer.training_profile.models import TrainingBaseProfile + +_PROFILES: Dict[str, TrainingBaseProfile] = {} + + +def _register(p: TrainingBaseProfile) -> None: + if p.key in _PROFILES: + raise ValueError(f"Duplicate training base profile key: {p.key}") + _PROFILES[p.key] = p + + +def get_training_base_profile(key: str) -> TrainingBaseProfile: + if key not in _PROFILES: + raise KeyError(f"Unknown training base profile: {key}") + return _PROFILES[key] + + +def try_get_training_base_profile(key: str) -> Optional[TrainingBaseProfile]: + return _PROFILES.get(key) + + +def list_training_base_profile_keys() -> tuple[str, ...]: + return tuple(sorted(_PROFILES.keys())) + + +_register( + TrainingBaseProfile( + key="scaffold_aerobic_base", + label="Scaffold: aerobic base profile", + default_template_id="scaffold_example_aerobic_v1", + allowed_dimension_keys=None, + ) +) + +_register( + TrainingBaseProfile( + key="scaffold_strength_base", + label="Scaffold: strength base profile", + default_template_id="scaffold_example_strength_v1", + allowed_dimension_keys=frozenset({"effort"}), + ) +) diff --git a/backend/data_layer/training_profile/resolver.py b/backend/data_layer/training_profile/resolver.py new file mode 100644 index 0000000..98d441d --- /dev/null +++ b/backend/data_layer/training_profile/resolver.py @@ -0,0 +1,160 @@ +""" +Layer 1 entry: resolve a multi-dimensional training evaluation from a template. + +Pure calculation orchestration — no DB, no HTTP, no formatting for KI/charts. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, List, Mapping, Optional + +from data_layer.training_profile.algorithms.registry import get_algorithm +from data_layer.training_profile.models import ( + CalculationTemplate, + DimensionResult, + DimensionSpec, + TrainingBaseProfile, + TrainingEvaluationResult, +) + + +def _required_inputs_present( + activity_inputs: Mapping[str, Any], keys: tuple[str, ...] +) -> tuple[bool, List[str]]: + missing: List[str] = [] + for k in keys: + if k not in activity_inputs or activity_inputs[k] is None: + missing.append(k) + return (len(missing) == 0, missing) + + +def _confidence_level(total_dims: int, dims_with_any_missing: int) -> str: + if total_dims == 0: + return "insufficient" + if dims_with_any_missing == 0: + return "high" + if dims_with_any_missing >= total_dims: + return "insufficient" + if dims_with_any_missing == 1: + return "medium" + return "low" + + +def _filter_dimensions( + template: CalculationTemplate, base_profile: Optional[TrainingBaseProfile] +) -> tuple[DimensionSpec, ...]: + if base_profile is None or base_profile.allowed_dimension_keys is None: + return template.dimensions + allowed = base_profile.allowed_dimension_keys + return tuple(d for d in template.dimensions if d.key in allowed) + + +def resolve_training_evaluation( + *, + activity_inputs: Mapping[str, Any], + template: CalculationTemplate, + base_profile: Optional[TrainingBaseProfile] = None, + include_trace: bool = False, +) -> TrainingEvaluationResult: + """ + Run all template dimensions, aggregate Focus Area contributions, attach evidence. + + activity_inputs: flat dict (e.g. avg_hr, duration_min, distance_km) supplied by caller. + """ + dimensions = _filter_dimensions(template, base_profile) + dimension_results: List[DimensionResult] = [] + contributions: Dict[str, float] = defaultdict(float) + evidence: Dict[str, Any] = { + "dimensions_total": len(dimensions), + "inputs_keys": sorted(activity_inputs.keys()), + } + trace: Optional[Dict[str, Any]] = {} if include_trace else None + + dims_with_missing = 0 + + for spec in dimensions: + ok, missing = _required_inputs_present(activity_inputs, spec.inputs) + if not ok: + dims_with_missing += 1 + dimension_results.append( + DimensionResult( + dimension_key=spec.key, + algorithm_id=spec.algorithm_id, + raw_score=0.0, + normalized_score=0.0, + missing_inputs=list(missing), + evidence={"skipped": True, "reason": "required_inputs_missing"}, + ) + ) + if trace is not None: + trace[spec.key] = {"skipped": True, "missing": missing} + continue + + algo = get_algorithm(spec.algorithm_id) + slice_inputs = {k: activity_inputs[k] for k in spec.inputs} + run = algo(inputs=slice_inputs, params=dict(spec.params)) + + if run.missing_inputs: + dims_with_missing += 1 + + dimension_results.append( + DimensionResult( + dimension_key=spec.key, + algorithm_id=spec.algorithm_id, + raw_score=run.raw_score, + normalized_score=run.normalized_score, + missing_inputs=list(run.missing_inputs), + evidence={"algorithm_detail": run.detail}, + ) + ) + + for m in spec.maps_to: + contributions[m.focus_area_key] += run.normalized_score * m.weight + + if trace is not None: + trace[spec.key] = { + "inputs": dict(slice_inputs), + "params": dict(spec.params), + "run": { + "raw_score": run.raw_score, + "normalized_score": run.normalized_score, + "missing_inputs": run.missing_inputs, + "detail": run.detail, + }, + "maps_to": [(x.focus_area_key, x.weight) for x in spec.maps_to], + } + + conf = _confidence_level(len(dimensions), dims_with_missing) + evidence["dimensions_with_missing_or_failed"] = dims_with_missing + + return TrainingEvaluationResult( + template_id=template.id, + template_version=template.version, + base_profile_key=base_profile.key if base_profile else None, + dimension_results=dimension_results, + focus_area_contributions=dict(contributions), + confidence=conf, + evidence=evidence, + trace=trace, + ) + + +def resolve_for_base_profile( + *, + activity_inputs: Mapping[str, Any], + base_profile_key: str, + include_trace: bool = False, +) -> TrainingEvaluationResult: + """Convenience: load profile + default template from registries.""" + from data_layer.training_profile.profiles.registry import get_training_base_profile + from data_layer.training_profile.templates.registry import get_calculation_template + + profile = get_training_base_profile(base_profile_key) + template = get_calculation_template(profile.default_template_id) + return resolve_training_evaluation( + activity_inputs=activity_inputs, + template=template, + base_profile=profile, + include_trace=include_trace, + ) diff --git a/backend/data_layer/training_profile/templates/__init__.py b/backend/data_layer/training_profile/templates/__init__.py new file mode 100644 index 0000000..5081da5 --- /dev/null +++ b/backend/data_layer/training_profile/templates/__init__.py @@ -0,0 +1,5 @@ +"""Declarative calculation templates (in-code registry, scaffold).""" + +from .registry import get_calculation_template, list_calculation_template_ids + +__all__ = ["get_calculation_template", "list_calculation_template_ids"] diff --git a/backend/data_layer/training_profile/templates/registry.py b/backend/data_layer/training_profile/templates/registry.py new file mode 100644 index 0000000..bdaeb0a --- /dev/null +++ b/backend/data_layer/training_profile/templates/registry.py @@ -0,0 +1,96 @@ +""" +Example calculation templates — declarative only; algorithms are referenced by id. + +These are scaffolding examples, not production coaching rules. +""" + +from __future__ import annotations + +from typing import Dict + +from data_layer.training_profile.models import CalculationTemplate, DimensionSpec, FocusAreaMapping + +_TEMPLATES: Dict[str, CalculationTemplate] = {} + + +def _register(t: CalculationTemplate) -> None: + if t.id in _TEMPLATES: + raise ValueError(f"Duplicate template id: {t.id}") + _TEMPLATES[t.id] = t + + +def get_calculation_template(template_id: str) -> CalculationTemplate: + if template_id not in _TEMPLATES: + raise KeyError(f"Unknown calculation template: {template_id}") + return _TEMPLATES[template_id] + + +def list_calculation_template_ids() -> tuple[str, ...]: + return tuple(sorted(_TEMPLATES.keys())) + + +# --- Example templates (illustrative) --- + +_example_aerobic = CalculationTemplate( + id="scaffold_example_aerobic_v1", + version="1", + label="Scaffold: aerobic-style intensity + volume (example)", + dimensions=( + DimensionSpec( + key="intensity", + algorithm_id="threshold_band", + inputs=("avg_hr", "duration_min"), + params={ + "value_key": "avg_hr", + "bands": [ + {"max": 120, "score": 0.25}, + {"max": 140, "score": 0.55}, + {"max": 160, "score": 0.85}, + {"max": None, "score": 1.0}, + ], + }, + maps_to=( + FocusAreaMapping("aerobic_endurance", 0.7), + FocusAreaMapping("cardiovascular_health", 0.3), + ), + ), + DimensionSpec( + key="volume", + algorithm_id="linear_range", + inputs=("duration_min", "distance_km"), + params={ + "value_key": "duration_min", + "min_value": 10.0, + "max_value": 90.0, + "invert": False, + }, + maps_to=(FocusAreaMapping("aerobic_endurance", 0.8),), + ), + ), +) + +_example_strength = CalculationTemplate( + id="scaffold_example_strength_v1", + version="1", + label="Scaffold: strength session load (example)", + dimensions=( + DimensionSpec( + key="effort", + algorithm_id="linear_range", + inputs=("duration_min",), + params={ + "value_key": "duration_min", + "min_value": 20.0, + "max_value": 75.0, + "invert": False, + }, + maps_to=( + FocusAreaMapping("strength", 0.9), + FocusAreaMapping("strength_endurance", 0.1), + ), + ), + ), +) + +_register(_example_aerobic) +_register(_example_strength) diff --git a/backend/main.py b/backend/main.py index 22d2717..e0798ee 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,6 +28,7 @@ from routers import goal_types, goal_progress, training_phases, fitness_tests # from routers import charts # Phase 0c Multi-Layer Architecture from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog from routers import workflows # Phase 2 Workflow Engine - Execution +from routers import reference_values # Persönliche Referenzwerte (Profil) # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -115,6 +116,7 @@ app.include_router(charts.router) # /api/charts/* (Phase 0c Charts # Phase 1-2 Workflow Engine app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog) app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution) +app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/037_profile_reference_values.sql b/backend/migrations/037_profile_reference_values.sql new file mode 100644 index 0000000..a134ad5 --- /dev/null +++ b/backend/migrations/037_profile_reference_values.sql @@ -0,0 +1,99 @@ +-- Migration 037: Persönliche Referenzwerte (Typkatalog + historische Werte pro Profil) +-- Date: 2026-04-06 +-- Purpose: System-definierte Referenztyp-Schlüssel; Nutzer pflegt nur historische Einträge. + +CREATE TABLE IF NOT EXISTS reference_value_types ( + id SERIAL PRIMARY KEY, + key VARCHAR(64) NOT NULL UNIQUE, + label VARCHAR(200) NOT NULL, + description TEXT, + default_unit VARCHAR(32), + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE reference_value_types IS + 'Systemdefinierte Typen persönlicher Referenzwerte (kein Nutzer-CRUD auf Typen)'; + +CREATE TABLE IF NOT EXISTS profile_reference_values ( + id BIGSERIAL PRIMARY KEY, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + reference_value_type_id INT NOT NULL REFERENCES reference_value_types(id) ON DELETE RESTRICT, + effective_date DATE NOT NULL, + value_numeric NUMERIC(18, 6), + value_text TEXT, + unit VARCHAR(32) NOT NULL, + source TEXT, + confidence NUMERIC(5, 2), + method TEXT, + notes TEXT, + extra JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT profile_reference_values_value_ck CHECK ( + value_numeric IS NOT NULL OR (value_text IS NOT NULL AND length(trim(value_text)) > 0) + ) +); + +COMMENT ON TABLE profile_reference_values IS + 'Historische Referenzwerte pro Profil (kein Überschreiben eines Einzel-»aktuellen« Werts)'; + +CREATE INDEX IF NOT EXISTS idx_prv_profile_type_date + ON profile_reference_values (profile_id, reference_value_type_id, effective_date DESC); + +CREATE INDEX IF NOT EXISTS idx_prv_profile + ON profile_reference_values (profile_id); + +-- Seed: nur Typdefinitionen, keine Benutzerwerte +INSERT INTO reference_value_types (key, label, description, default_unit, sort_order, active) VALUES + ( + 'max_heart_rate', + 'Maximale Herzfrequenz', + 'Individuelle HRmax (z. B. aus Leistungstest oder geschätzt).', + 'bpm', + 10, + TRUE + ), + ( + 'anaerobic_threshold_hr', + 'Anaerober Schwellenwert (Herzfrequenz)', + 'Laktatschwelle / anaerober Schwellenpuls.', + 'bpm', + 20, + TRUE + ), + ( + 'aerobic_threshold_hr', + 'Aerober Schwellenwert (Herzfrequenz)', + 'Erster aerobet/schwellenanaloger Trainingsbereich (GA2).', + 'bpm', + 30, + TRUE + ), + ( + 'training_frequency_weekly', + 'Trainingshäufigkeit', + 'Geplante oder beobachtete Einheiten pro Woche.', + 'Sessions/Woche', + 40, + TRUE + ), + ( + 'fitness_level', + 'Fitnesslevel', + 'Subjektive oder normierte Einstufung (Zahl oder Kurzbeschreibung im Freitextfeld).', + 'Stufe', + 50, + TRUE + ), + ( + 'resting_heart_rate', + 'Ruhepuls (Referenz)', + 'Ruheherzfrequenz als persönliche Referenz (z. B. morgens).', + 'bpm', + 15, + TRUE + ) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py new file mode 100644 index 0000000..5bb0a1d --- /dev/null +++ b/backend/routers/reference_values.py @@ -0,0 +1,355 @@ +""" +Persönliche Referenzwerte (profilorientiert) + +Typkatalog system-seeded; Nutzer pflegt historische Werte pro aktivem Profil. +""" +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from pydantic import BaseModel, Field, model_validator +from psycopg2.extras import Json + +from auth import require_auth +from db import get_db, get_cursor, r2d +from routers.profiles import get_pid + +router = APIRouter(prefix="/api", tags=["reference-values"]) + + +def _row_to_api(d: dict[str, Any]) -> dict[str, Any]: + if not d: + return d + out = dict(d) + ed = out.get("effective_date") + if ed is not None and hasattr(ed, "isoformat"): + out["effective_date"] = ed.isoformat() + ca = out.get("created_at") + if ca is not None and hasattr(ca, "isoformat"): + out["created_at"] = ca.isoformat() + ua = out.get("updated_at") + if ua is not None and hasattr(ua, "isoformat"): + out["updated_at"] = ua.isoformat() + vn = out.get("value_numeric") + if vn is not None and isinstance(vn, Decimal): + out["value_numeric"] = float(vn) + conf = out.get("confidence") + if conf is not None and isinstance(conf, Decimal): + out["confidence"] = float(conf) + return out + + +def _get_type_by_key(cur, key: str, require_active: bool = True) -> dict: + q = ( + "SELECT id, key, label, default_unit, active FROM reference_value_types WHERE key = %s " + ) + if require_active: + q += "AND active = TRUE " + cur.execute(q, (key,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Referenztyp nicht gefunden") + return r2d(row) + + +class ProfileReferenceValueCreate(BaseModel): + reference_value_type_key: str = Field(..., min_length=1, max_length=64) + effective_date: str + value_numeric: Optional[float] = None + value_text: Optional[str] = None + unit: Optional[str] = Field(None, max_length=32) + source: Optional[str] = None + confidence: Optional[float] = Field(None, ge=0, le=100) + method: Optional[str] = None + notes: Optional[str] = None + extra: Optional[dict] = None + + @model_validator(mode="after") + def _value_present(self): + has_num = self.value_numeric is not None + has_txt = self.value_text is not None and str(self.value_text).strip() != "" + if not has_num and not has_txt: + raise ValueError("Mindestens value_numeric oder value_text erforderlich") + return self + + +class ProfileReferenceValueUpdate(BaseModel): + effective_date: Optional[str] = None + value_numeric: Optional[float] = None + value_text: Optional[str] = None + unit: Optional[str] = Field(None, max_length=32) + source: Optional[str] = None + confidence: Optional[float] = Field(None, ge=0, le=100) + method: Optional[str] = None + notes: Optional[str] = None + extra: Optional[dict] = None + + +@router.get("/reference-value-types") +def list_reference_value_types(session: dict = Depends(require_auth)): + """Alle aktiven Referenztyp-Definitionen (für dynamische UI).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, key, label, description, default_unit, sort_order, active, metadata, created_at + FROM reference_value_types + WHERE active = TRUE + ORDER BY sort_order ASC, id ASC + """ + ) + return [_row_to_api(r2d(r)) for r in cur.fetchall()] + + +@router.get("/profile-reference-values") +def list_profile_reference_values( + type_key: str = Query(..., description="Schlüssel aus reference_value_types.key"), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """Historische Einträge eines Typs für das aktive Profil (neueste zuerst).""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + t = _get_type_by_key(cur, type_key, require_active=True) + cur.execute( + """ + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.profile_id = %s AND rt.key = %s + ORDER BY v.effective_date DESC, v.created_at DESC + """, + (pid, t["key"]), + ) + return [_row_to_api(r2d(r)) for r in cur.fetchall()] + + +@router.post("/profile-reference-values") +def create_profile_reference_value( + body: ProfileReferenceValueCreate, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = get_pid(x_profile_id) + try: + datetime.strptime(body.effective_date, "%Y-%m-%d") + except ValueError: + raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD") + + with get_db() as conn: + cur = get_cursor(conn) + t = _get_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True) + unit = (body.unit or "").strip() or (t.get("default_unit") or "").strip() + if not unit: + raise HTTPException(400, "Bitte eine Einheit angeben (kein Standard für diesen Typ definiert).") + + vnum = body.value_numeric + vtxt = (body.value_text.strip() if body.value_text is not None else None) or None + if vnum is None and not vtxt: + raise HTTPException(400, "Mindestens value_numeric oder value_text erforderlich.") + + extra = body.extra if body.extra is not None else {} + + cur.execute( + """ + INSERT INTO profile_reference_values ( + profile_id, reference_value_type_id, effective_date, + value_numeric, value_text, unit, source, confidence, method, notes, extra + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + pid, + t["id"], + body.effective_date, + vnum, + vtxt, + unit, + body.source, + body.confidence, + body.method, + body.notes, + Json(extra), + ), + ) + new_id = cur.fetchone()["id"] + + cur.execute( + """ + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.id = %s AND v.profile_id = %s + """, + (new_id, pid), + ) + return _row_to_api(r2d(cur.fetchone())) + + +@router.put("/profile-reference-values/{entry_id}") +def update_profile_reference_value( + entry_id: int, + body: ProfileReferenceValueUpdate, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = get_pid(x_profile_id) + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + + if patch.get("effective_date"): + try: + datetime.strptime(patch["effective_date"], "%Y-%m-%d") + except ValueError: + raise HTTPException(400, "Ungültiges Datum. Format: YYYY-MM-DD") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT v.*, rt.default_unit + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.id = %s AND v.profile_id = %s + """, + (entry_id, pid), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Eintrag nicht gefunden") + cur_row = r2d(row) + + new_ed = patch.get("effective_date", cur_row["effective_date"]) + if hasattr(new_ed, "isoformat"): + new_ed = new_ed.isoformat() + + new_num = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric") + new_txt_raw = patch["value_text"] if "value_text" in patch else cur_row.get("value_text") + new_txt = (new_txt_raw.strip() if isinstance(new_txt_raw, str) else new_txt_raw) or None + if new_num is not None and isinstance(new_num, Decimal): + new_num = float(new_num) + + if new_num is None and not new_txt: + raise HTTPException(400, "Mindestens value_numeric oder value_text erforderlich.") + + if "unit" in patch: + new_unit = str(patch["unit"]).strip() + if new_unit == "": + default_u = (cur_row.get("default_unit") or "").strip() + nu = default_u or cur_row["unit"] + else: + nu = new_unit + else: + nu = cur_row["unit"] + if not nu: + raise HTTPException(400, "Einheit darf nicht leer sein.") + + updates: dict[str, Any] = { + "effective_date": new_ed, + "value_numeric": new_num, + "value_text": new_txt, + "unit": nu, + } + if "source" in patch: + updates["source"] = patch["source"] + if "confidence" in patch: + updates["confidence"] = patch["confidence"] + if "method" in patch: + updates["method"] = patch["method"] + if "notes" in patch: + updates["notes"] = patch["notes"] + if "extra" in patch: + updates["extra"] = Json(patch["extra"] if patch["extra"] is not None else {}) + + set_parts = [f"{k} = %s" for k in updates] + vals = list(updates.values()) + [entry_id, pid] + cur.execute( + f""" + UPDATE profile_reference_values SET {", ".join(set_parts)}, updated_at = NOW() + WHERE id = %s AND profile_id = %s + """, + tuple(vals), + ) + + cur.execute( + """ + SELECT + v.id, + v.profile_id, + v.reference_value_type_id, + v.effective_date, + v.value_numeric, + v.value_text, + v.unit, + v.source, + v.confidence, + v.method, + v.notes, + v.extra, + v.created_at, + v.updated_at, + rt.key AS type_key, + rt.label AS type_label + FROM profile_reference_values v + JOIN reference_value_types rt ON rt.id = v.reference_value_type_id + WHERE v.id = %s AND v.profile_id = %s + """, + (entry_id, pid), + ) + return _row_to_api(r2d(cur.fetchone())) + + +@router.delete("/profile-reference-values/{entry_id}") +def delete_profile_reference_value( + entry_id: int, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM profile_reference_values WHERE id = %s AND profile_id = %s RETURNING id", + (entry_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + return {"ok": True} diff --git a/backend/tests/test_training_profile_resolver.py b/backend/tests/test_training_profile_resolver.py new file mode 100644 index 0000000..de91294 --- /dev/null +++ b/backend/tests/test_training_profile_resolver.py @@ -0,0 +1,138 @@ +""" +Unit tests: Layer 1 training profile resolver scaffold. + +No database; pure template + algorithm + resolver behavior. +""" + +import pytest + +from data_layer.training_profile import ( + CalculationTemplate, + DimensionSpec, + FocusAreaMapping, + TrainingEvaluationResult, + resolve_for_base_profile, + resolve_training_evaluation, +) +from data_layer.training_profile.algorithms.registry import ( + get_algorithm, + list_algorithm_ids, + register_algorithm, +) +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.training_profile.profiles.registry import get_training_base_profile +from data_layer.training_profile.templates.registry import get_calculation_template + + +class TestAlgorithmRegistry: + def test_builtin_algorithms_registered(self): + ids = list_algorithm_ids() + assert "threshold_band" in ids + assert "linear_range" in ids + + def test_get_algorithm_runs_threshold(self): + fn = get_algorithm("threshold_band") + r = fn( + inputs={"avg_hr": 130.0}, + params={ + "value_key": "avg_hr", + "bands": [ + {"max": 120, "score": 0.2}, + {"max": 150, "score": 0.8}, + {"max": None, "score": 1.0}, + ], + }, + ) + assert r.normalized_score == 0.8 + + def test_duplicate_register_raises(self): + def dummy(*, inputs, params): + return AlgorithmRunResult(0.0, 0.0, []) + + with pytest.raises(ValueError, match="already registered"): + register_algorithm("threshold_band", dummy) + + +class TestResolver: + def test_example_template_resolves(self): + tpl = get_calculation_template("scaffold_example_aerobic_v1") + result = resolve_training_evaluation( + activity_inputs={ + "avg_hr": 135.0, + "duration_min": 45.0, + "distance_km": 10.0, + }, + template=tpl, + ) + assert isinstance(result, TrainingEvaluationResult) + assert result.template_id == "scaffold_example_aerobic_v1" + assert result.confidence == "high" + assert "aerobic_endurance" in result.focus_area_contributions + assert len(result.dimension_results) == 2 + for dr in result.dimension_results: + assert dr.missing_inputs == [] + + def test_missing_required_input_skips_dimension(self): + tpl = get_calculation_template("scaffold_example_aerobic_v1") + result = resolve_training_evaluation( + activity_inputs={"avg_hr": 135.0}, + template=tpl, + ) + assert result.confidence in ("medium", "low", "insufficient") + skipped = [d for d in result.dimension_results if d.evidence.get("skipped")] + assert len(skipped) >= 1 + + def test_base_profile_filters_dimensions(self): + profile = get_training_base_profile("scaffold_strength_base") + tpl = get_calculation_template(profile.default_template_id) + result = resolve_training_evaluation( + activity_inputs={"duration_min": 50.0}, + template=tpl, + base_profile=profile, + ) + assert len(result.dimension_results) == 1 + assert result.dimension_results[0].dimension_key == "effort" + + def test_resolve_for_base_profile_convenience(self): + result = resolve_for_base_profile( + activity_inputs={"duration_min": 40.0}, + base_profile_key="scaffold_strength_base", + include_trace=True, + ) + assert result.base_profile_key == "scaffold_strength_base" + assert result.trace is not None + assert "effort" in result.trace + + def test_to_serializable(self): + tpl = get_calculation_template("scaffold_example_strength_v1") + r = resolve_training_evaluation( + activity_inputs={"duration_min": 45.0}, + template=tpl, + ) + d = r.to_serializable() + assert d["template_id"] == tpl.id + assert "focus_area_contributions" in d + assert isinstance(d["dimension_results"], list) + + +class TestCustomTemplate: + def test_unknown_algorithm_raises(self): + bad = CalculationTemplate( + id="bad", + version="1", + label="bad", + dimensions=( + DimensionSpec( + key="x", + algorithm_id="does_not_exist", + inputs=("a",), + params={}, + maps_to=(FocusAreaMapping("strength", 1.0),), + ), + ), + ) + with pytest.raises(KeyError): + resolve_training_evaluation( + activity_inputs={"a": 1.0}, + template=bad, + ) diff --git a/backend/version.py b/backend/version.py index 739a463..904d454 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,11 +9,12 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9n" BUILD_DATE = "2026-04-05" -DB_SCHEMA_VERSION = "20260403" # Migration 034 +DB_SCHEMA_VERSION = "20260406" # Migration 037 MODULE_VERSIONS = { "auth": "1.2.0", "profiles": "1.1.0", + "reference_values": "1.0.0", "weight": "1.0.3", "circumference": "1.0.1", "caliper": "1.0.1", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 332cad7..affacd5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,6 +22,7 @@ import NutritionPage from './pages/NutritionPage' import ActivityPage from './pages/ActivityPage' import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' +import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' import GuidePage from './pages/GuidePage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminFeaturesPage from './pages/AdminFeaturesPage' @@ -225,6 +226,7 @@ function AppShell() { }/> }/> }/> + }/> }> }> } /> diff --git a/frontend/src/pages/ProfileReferenceValuesPage.jsx b/frontend/src/pages/ProfileReferenceValuesPage.jsx new file mode 100644 index 0000000..021b8d0 --- /dev/null +++ b/frontend/src/pages/ProfileReferenceValuesPage.jsx @@ -0,0 +1,377 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { ArrowLeft, Gauge, Pencil, Trash2, Plus } from 'lucide-react' +import { api } from '../utils/api' + +function splitValueInput(raw) { + const s = String(raw).trim() + if (!s) return { value_numeric: null, value_text: null } + const normalized = s.replace(',', '.') + if (/^-?\d+(\.\d+)?$/.test(normalized)) { + const n = Number(normalized) + if (!Number.isNaN(n)) return { value_numeric: n, value_text: null } + } + return { value_numeric: null, value_text: s } +} + +function formatEntryValue(row) { + if (row.value_numeric != null && row.value_numeric !== '') { + const n = Number(row.value_numeric) + return Number.isFinite(n) ? String(n) : String(row.value_numeric) + } + return row.value_text != null ? String(row.value_text) : '–' +} + +export default function ProfileReferenceValuesPage() { + const [types, setTypes] = useState([]) + const [selectedKey, setSelectedKey] = useState('') + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [listLoading, setListLoading] = useState(false) + const [error, setError] = useState(null) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState({ + effective_date: new Date().toISOString().split('T')[0], + value: '', + unit: '', + notes: '', + }) + + const selectedType = types.find((t) => t.key === selectedKey) + + const loadTypes = useCallback(async () => { + try { + setLoading(true) + const data = await api.listReferenceValueTypes() + setTypes(Array.isArray(data) ? data : []) + setError(null) + } catch (e) { + setError(e.message || 'Typen konnten nicht geladen werden') + setTypes([]) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadTypes() + }, [loadTypes]) + + useEffect(() => { + if (types.length && !selectedKey) { + setSelectedKey(types[0].key) + } + }, [types, selectedKey]) + + const loadEntries = useCallback(async () => { + if (!selectedKey) return + try { + setListLoading(true) + const data = await api.listProfileReferenceValues(selectedKey) + setEntries(Array.isArray(data) ? data : []) + setError(null) + } catch (e) { + setError(e.message || 'Einträge konnten nicht geladen werden') + setEntries([]) + } finally { + setListLoading(false) + } + }, [selectedKey]) + + useEffect(() => { + setEditingId(null) + loadEntries() + }, [loadEntries]) + + const resetForm = () => { + setEditingId(null) + setForm({ + effective_date: new Date().toISOString().split('T')[0], + value: '', + unit: selectedType?.default_unit || '', + notes: '', + }) + } + + useEffect(() => { + if (selectedType && !editingId) { + setForm((f) => ({ + ...f, + unit: selectedType.default_unit || f.unit || '', + })) + } + }, [selectedType, editingId]) + + const handleSubmit = async (e) => { + e.preventDefault() + if (!selectedKey) return + const parts = splitValueInput(form.value) + if (parts.value_numeric == null && !parts.value_text) { + setError('Bitte einen Wert eingeben.') + return + } + const unit = (form.unit || '').trim() || (selectedType?.default_unit || '').trim() + if (!unit) { + setError('Bitte eine Einheit angeben.') + return + } + try { + setError(null) + const payload = { + reference_value_type_key: selectedKey, + effective_date: form.effective_date, + value_numeric: parts.value_numeric, + value_text: parts.value_text, + unit, + notes: form.notes.trim() || null, + } + if (editingId) { + await api.updateProfileReferenceValue(editingId, { + effective_date: payload.effective_date, + value_numeric: payload.value_numeric, + value_text: payload.value_text, + unit: payload.unit, + notes: payload.notes, + }) + } else { + await api.createProfileReferenceValue(payload) + } + resetForm() + await loadEntries() + } catch (err) { + setError(err.message || 'Speichern fehlgeschlagen') + } + } + + const startEdit = (row) => { + setEditingId(row.id) + setForm({ + effective_date: String(row.effective_date || '').slice(0, 10), + value: formatEntryValue(row), + unit: row.unit || selectedType?.default_unit || '', + notes: row.notes || '', + }) + } + + const handleDelete = async (id) => { + if (!confirm('Diesen Eintrag wirklich löschen?')) return + try { + setError(null) + await api.deleteProfileReferenceValue(id) + if (editingId === id) resetForm() + await loadEntries() + } catch (err) { + setError(err.message || 'Löschen fehlgeschlagen') + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+ + Zurück zu Einstellungen + +

+ + Referenzwerte +

+

+ Persönliche Kennwerte für das aktive Profil – historisch gespeichert, typgesteuert. Neue Typen + erscheinen automatisch, sobald sie im System ergänzt werden. +

+
+ + {error && ( +
+ {error} +
+ )} + + {types.length === 0 ? ( +
+

Keine Referenztypen definiert.

+
+ ) : ( + <> +
+
Referenztyp
+ + + {selectedType?.description && ( +

+ {selectedType.description} +

+ )} +
+ +
+
+ + {editingId ? 'Eintrag bearbeiten' : 'Neuer Eintrag'} +
+
+
+
+ + setForm((f) => ({ ...f, effective_date: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, value: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, unit: e.target.value }))} + /> +
+
+ +