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.
This commit is contained in:
parent
e7dedd527f
commit
f0e6fd04fb
|
|
@ -19,6 +19,8 @@ Modules:
|
||||||
- goals: Active goals, progress, projections
|
- goals: Active goals, progress, projections
|
||||||
- correlations: Lag-analysis, plateau detection
|
- correlations: Lag-analysis, plateau detection
|
||||||
- utils: Shared functions (confidence, baseline, outliers)
|
- 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
|
Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
|
|
|
||||||
36
backend/data_layer/training_profile/__init__.py
Normal file
36
backend/data_layer/training_profile/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
13
backend/data_layer/training_profile/algorithms/__init__.py
Normal file
13
backend/data_layer/training_profile/algorithms/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
23
backend/data_layer/training_profile/algorithms/base.py
Normal file
23
backend/data_layer/training_profile/algorithms/base.py
Normal file
|
|
@ -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:
|
||||||
|
...
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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},
|
||||||
|
)
|
||||||
|
|
@ -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},
|
||||||
|
)
|
||||||
47
backend/data_layer/training_profile/algorithms/registry.py
Normal file
47
backend/data_layer/training_profile/algorithms/registry.py
Normal file
|
|
@ -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()
|
||||||
121
backend/data_layer/training_profile/models.py
Normal file
121
backend/data_layer/training_profile/models.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
13
backend/data_layer/training_profile/profiles/__init__.py
Normal file
13
backend/data_layer/training_profile/profiles/__init__.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
52
backend/data_layer/training_profile/profiles/registry.py
Normal file
52
backend/data_layer/training_profile/profiles/registry.py
Normal file
|
|
@ -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"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
160
backend/data_layer/training_profile/resolver.py
Normal file
160
backend/data_layer/training_profile/resolver.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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"]
|
||||||
96
backend/data_layer/training_profile/templates/registry.py
Normal file
96
backend/data_layer/training_profile/templates/registry.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -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 charts # Phase 0c Multi-Layer Architecture
|
||||||
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
||||||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||||
|
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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
|
# Phase 1-2 Workflow Engine
|
||||||
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
|
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(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
||||||
|
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||||
|
|
||||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
99
backend/migrations/037_profile_reference_values.sql
Normal file
99
backend/migrations/037_profile_reference_values.sql
Normal file
|
|
@ -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;
|
||||||
355
backend/routers/reference_values.py
Normal file
355
backend/routers/reference_values.py
Normal file
|
|
@ -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}
|
||||||
138
backend/tests/test_training_profile_resolver.py
Normal file
138
backend/tests/test_training_profile_resolver.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -9,11 +9,12 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
APP_VERSION = "0.9n"
|
APP_VERSION = "0.9n"
|
||||||
BUILD_DATE = "2026-04-05"
|
BUILD_DATE = "2026-04-05"
|
||||||
DB_SCHEMA_VERSION = "20260403" # Migration 034
|
DB_SCHEMA_VERSION = "20260406" # Migration 037
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
"profiles": "1.1.0",
|
"profiles": "1.1.0",
|
||||||
|
"reference_values": "1.0.0",
|
||||||
"weight": "1.0.3",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import NutritionPage from './pages/NutritionPage'
|
||||||
import ActivityPage from './pages/ActivityPage'
|
import ActivityPage from './pages/ActivityPage'
|
||||||
import Analysis from './pages/Analysis'
|
import Analysis from './pages/Analysis'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||||
import GuidePage from './pages/GuidePage'
|
import GuidePage from './pages/GuidePage'
|
||||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
|
|
@ -225,6 +226,7 @@ function AppShell() {
|
||||||
<Route path="/goals" element={<GoalsPage/>}/>
|
<Route path="/goals" element={<GoalsPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<Route path="/analysis" element={<Analysis/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
|
<Route path="/settings/reference-values" element={<ProfileReferenceValuesPage/>}/>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="admin" element={<AdminShell />}>
|
<Route path="admin" element={<AdminShell />}>
|
||||||
<Route index element={<AdminHomePage />} />
|
<Route index element={<AdminHomePage />} />
|
||||||
|
|
|
||||||
377
frontend/src/pages/ProfileReferenceValuesPage.jsx
Normal file
377
frontend/src/pages/ProfileReferenceValuesPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 88 }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, textDecoration: 'none', marginBottom: 12 }}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Zurück zu Einstellungen
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Gauge size={26} color="var(--accent)" />
|
||||||
|
Referenzwerte
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Persönliche Kennwerte für das aktive Profil – historisch gespeichert, typgesteuert. Neue Typen
|
||||||
|
erscheinen automatisch, sobald sie im System ergänzt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
background: '#FCEBEB',
|
||||||
|
color: '#991B1B',
|
||||||
|
fontSize: 14,
|
||||||
|
border: '1px solid #FECACA',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{types.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Referenztypen definiert.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Referenztyp</div>
|
||||||
|
<label className="form-label" htmlFor="ref-type-select">
|
||||||
|
Wähle einen Kennwert
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-type-select"
|
||||||
|
className="form-input"
|
||||||
|
value={selectedKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedKey(e.target.value)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{types.map((t) => (
|
||||||
|
<option key={t.key} value={t.key}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedType?.description && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
{selectedType.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{editingId ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="form-row" style={{ flexDirection: 'column', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-date">
|
||||||
|
Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-date"
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.effective_date}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, effective_date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-value">
|
||||||
|
Wert
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-value"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Zahl oder Text (z. B. 178 oder mittel)"
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-unit">
|
||||||
|
Einheit
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-unit"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder={selectedType?.default_unit || 'z. B. bpm'}
|
||||||
|
value={form.unit}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, unit: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="form-label" htmlFor="ref-notes">
|
||||||
|
Notiz (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-notes"
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
placeholder="Kontext, Messmethode …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
|
||||||
|
<button type="submit" className="btn btn-primary" style={{ flex: 1, minWidth: 120 }}>
|
||||||
|
{editingId ? 'Speichern' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{editingId && (
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={resetForm}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Verlauf</div>
|
||||||
|
{listLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: 14, margin: 0 }}>
|
||||||
|
Noch keine Einträge für diesen Typ. Lege oben einen ersten Wert an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Datum</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Wert</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Einheit</th>
|
||||||
|
<th style={{ padding: '8px 6px', width: 100 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((row) => (
|
||||||
|
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '10px 6px', whiteSpace: 'nowrap' }}>
|
||||||
|
{String(row.effective_date || '').slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{formatEntryValue(row)}</td>
|
||||||
|
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{row.unit}</td>
|
||||||
|
<td style={{ padding: '6px', textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', marginRight: 6 }}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', color: 'var(--danger)' }}
|
||||||
|
title="Löschen"
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target } from 'lucide-react'
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, Gauge } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Gauge size={15} color="var(--accent)" /> Referenzwerte
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
Persönliche Kennwerte (z. B. HF-Schwellen, Trainingshäufigkeit) – historisch zum Profil gespeichert,
|
||||||
|
keine Admin-Konfiguration.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/settings/reference-values"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
|
>
|
||||||
|
Referenzwerte verwalten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ export const api = {
|
||||||
getProfile: () => req('/profile'),
|
getProfile: () => req('/profile'),
|
||||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
||||||
|
|
||||||
|
// Persönliche Referenzwerte (Profil, historisch)
|
||||||
|
listReferenceValueTypes: () => req('/reference-value-types'),
|
||||||
|
listProfileReferenceValues: (typeKey) =>
|
||||||
|
req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`),
|
||||||
|
createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)),
|
||||||
|
updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)),
|
||||||
|
deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
// Weight
|
// Weight
|
||||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user