feat: Add personal reference values management in settings and API
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- 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:
Lars 2026-04-06 19:45:06 +02:00
parent e7dedd527f
commit f0e6fd04fb
23 changed files with 1719 additions and 2 deletions

View File

@ -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

View 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",
]

View 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",
]

View 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:
...

View File

@ -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"]

View File

@ -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},
)

View File

@ -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},
)

View 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()

View 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,
}

View 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",
]

View 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"}),
)
)

View 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,
)

View File

@ -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"]

View 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)

View File

@ -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("/")

View 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;

View 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}

View 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,
)

View File

@ -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",

View File

@ -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 />} />

View 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>
)
}

View File

@ -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

View File

@ -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})),