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 }))} + /> +
+
+ +