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
|
||||
- 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
|
||||
|
|
|
|||
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 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("/")
|
||||
|
|
|
|||
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"
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/goals" element={<GoalsPage/>}/>
|
||||
<Route path="/analysis" element={<Analysis/>}/>
|
||||
<Route path="/settings" element={<SettingsPage/>}/>
|
||||
<Route path="/settings/reference-values" element={<ProfileReferenceValuesPage/>}/>
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="admin" element={<AdminShell />}>
|
||||
<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 { 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 { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
|||
</button>
|
||||
</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-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ export const api = {
|
|||
getProfile: () => req('/profile'),
|
||||
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
|
||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user