diff --git a/CLAUDE.md b/CLAUDE.md index 6f9408c..aa859e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,7 @@ > | Coding-Regeln | `.claude/rules/CODING_RULES.md` | > | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` | > | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** | +> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** | ## Claude Code Verantwortlichkeiten @@ -17,6 +18,7 @@ - ✅ Bestehende Issues aktualisieren (Status, Beschreibung) - ✅ Issues bei Fertigstellung schließen - 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues +- Gitea **MCP** vs **CLI**: kurze Lese-/Kommentar-/PATCH-Vorgänge im Agent über MCP (`gitea_*`); **Beschreibung aus Datei**, sehr lange Bodies oder Skripte → `python scripts/gitea/gitea_api.py issues edit … --body-file` — `scripts/gitea/README.md` **Dokumentation:** - Code-Änderungen in CLAUDE.md dokumentieren @@ -841,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation) |Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions| |API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints| |Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen| +|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`| > Library-Dateien werden mit `/document` generiert und nach größeren > Änderungen aktualisiert. diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py new file mode 100644 index 0000000..8ed8713 --- /dev/null +++ b/backend/dashboard_layout_schema.py @@ -0,0 +1,127 @@ +""" +Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard. + +Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG. +""" +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + +from dashboard_widget_config import validate_widget_entry_config +from widget_catalog import ( + ALLOWED_WIDGET_IDS, + DEFAULT_LAB_WIDGET_IDS, + DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS, + WIDGET_CATALOG, +) + +# Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul) +__all__ = [ + "ALLOWED_WIDGET_IDS", + "DashboardLayoutPayload", + "DashboardWidgetEntry", + "coalesce_effective_layout", + "default_layout_dict", + "lab_default_layout_dict", + "product_default_layout_dict", +] + + +def lab_default_layout_dict() -> dict[str, Any]: + """Standard für Dashboard-Lab (Experimentier-Widgets).""" + on = DEFAULT_LAB_WIDGET_IDS + return { + "version": 1, + "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG], + } + + +def product_default_layout_dict() -> dict[str, Any]: + """Code-Fallback für die Produkt-Übersicht; live-Standard ggf. system_config (siehe get_product_default_base_dict).""" + on = DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS + return { + "version": 1, + "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG], + } + + +def default_layout_dict() -> dict[str, Any]: + """Alias: Produkt-Standard (coalesce, Reset). Lab nutzt lab_default_layout_dict().""" + return product_default_layout_dict() + + +class DashboardWidgetEntry(BaseModel): + id: str = Field(min_length=1, max_length=64) + enabled: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + @field_validator("config", mode="before") + @classmethod + def _config_coerce(cls, v: Any) -> dict[str, Any]: + if v is None: + return {} + if not isinstance(v, dict): + raise ValueError("config muss Objekt sein") + return v + + @model_validator(mode="after") + def _normalize_widget_config(self) -> DashboardWidgetEntry: + normalized = validate_widget_entry_config(self.id, self.config) + return self.model_copy(update={"config": normalized}) + + +class DashboardLayoutPayload(BaseModel): + version: Literal[1] = 1 + widgets: list[DashboardWidgetEntry] = Field(min_length=1, max_length=32) + + @model_validator(mode="after") + def _validate_widgets(self) -> DashboardLayoutPayload: + ids = [w.id for w in self.widgets] + if len(ids) != len(set(ids)): + raise ValueError("Doppelte widget id") + bad = [i for i in ids if i not in ALLOWED_WIDGET_IDS] + if bad: + raise ValueError(f"Unbekannte Widget-IDs: {bad}") + if not any(w.enabled for w in self.widgets): + raise ValueError("Mindestens ein Widget muss aktiv sein") + return self + + def to_stored_dict(self) -> dict[str, Any]: + out_widgets: list[dict[str, Any]] = [] + for w in self.widgets: + d: dict[str, Any] = {"id": w.id, "enabled": w.enabled} + if w.config: + d["config"] = dict(w.config) + out_widgets.append(d) + return {"version": self.version, "widgets": out_widgets} + + +def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]: + """ + Returns (has_custom, effective_layout). + has_custom=True nur wenn DB-Wert vorhanden und gültig (v1). + """ + if raw is None: + return False, default_layout_dict() + parsed_obj: Any = raw + if isinstance(raw, str): + import json + + try: + parsed_obj = json.loads(raw) + except json.JSONDecodeError: + return False, default_layout_dict() + if not isinstance(parsed_obj, dict): + return False, default_layout_dict() + try: + parsed = DashboardLayoutPayload.model_validate( + { + "version": parsed_obj.get("version", 1), + "widgets": parsed_obj.get("widgets", []), + } + ) + return True, parsed.to_stored_dict() + except Exception: + return False, default_layout_dict() diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py new file mode 100644 index 0000000..729c1ea --- /dev/null +++ b/backend/dashboard_widget_config.py @@ -0,0 +1,165 @@ +""" +Pro-Widget-Konfiguration im Dashboard-Layout (v1). + +Nur ausgewählte Widget-IDs dürfen nicht-leere config haben; bekannte Keys werden validiert. +""" +from __future__ import annotations + +import json +import math +import re +from typing import Any + +MAX_WIDGET_CONFIG_JSON_BYTES = 3072 + +WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ + "body_overview", + "activity_overview", + "kpi_board", + "quick_capture", + "trend_kcal_weight", + "nutrition_detail_charts", + "recovery_charts_panel", +}) + +_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({ + "show_weight", + "show_resting_hr", + "show_hrv", + "show_vo2_max", +}) + +_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) +_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") + + +def _config_json_size_bytes(config: dict[str, Any]) -> int: + return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) + + +def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein") + if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES: + raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)") + if not raw: + return {} + + if widget_id not in WIDGETS_ALLOWING_CONFIG: + raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + + if widget_id == "body_overview": + return _validate_chart_days_only(raw, label="body_overview") + if widget_id == "activity_overview": + return _validate_chart_days_only(raw, label="activity_overview") + if widget_id == "kpi_board": + return _validate_kpi_board_config(raw) + if widget_id == "quick_capture": + return _validate_quick_capture_config(raw) + if widget_id == "trend_kcal_weight": + return _validate_chart_days_only(raw, label="trend_kcal_weight") + if widget_id == "nutrition_detail_charts": + return _validate_chart_days_only(raw, label="nutrition_detail_charts") + if widget_id == "recovery_charts_panel": + return _validate_chart_days_only(raw, label="recovery_charts_panel") + + raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") + + +def _validate_quick_capture_config(raw: dict[str, Any]) -> dict[str, Any]: + label = "quick_capture" + unknown = set(raw) - _QUICK_CAPTURE_KEYS + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + out: dict[str, bool] = {} + for k in _QUICK_CAPTURE_KEYS: + if k not in raw: + continue + v = raw[k] + if not isinstance(v, bool): + raise ValueError(f"{label}: {k} muss boolean sein") + out[k] = v + merged = {k: True for k in _QUICK_CAPTURE_KEYS} + merged.update(out) + if not any(merged.values()): + raise ValueError(f"{label}: mindestens ein Bereich muss sichtbar sein (show_*)") + return out + + +def _kpi_tile_id_valid(tid: str) -> bool: + if tid in _KPI_TILE_FIXED: + return True + return bool(_KPI_REF_TILE_RE.match(tid)) + + +def _normalize_kpi_tile_entry(item: Any) -> str: + if isinstance(item, str): + tid = item.strip() + elif isinstance(item, dict) and "id" in item: + tid = str(item["id"]).strip() + else: + raise ValueError("kpi_board: jedes tiles-Element braucht eine id (String oder Objekt mit id)") + if not tid: + raise ValueError("kpi_board: leere Kachel-id") + if not _kpi_tile_id_valid(tid): + raise ValueError(f"kpi_board: ungültige Kachel-id {tid!r} (z. B. body_fat, avg_kcal, ref:typ_key)") + return tid + + +def _validate_kpi_board_config(raw: dict[str, Any]) -> dict[str, Any]: + if not raw: + return {} + # Legacy nur chart_days → entfallen, automatische Kachelwahl + if set(raw.keys()) <= frozenset({"chart_days"}): + return {} + allowed = frozenset({"tiles"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"kpi_board: unbekannte config-Felder: {sorted(unknown)}") + tiles_raw = raw.get("tiles") + if tiles_raw is None: + return {} + if not isinstance(tiles_raw, list): + raise ValueError("kpi_board: tiles muss eine Liste sein") + if len(tiles_raw) > 9: + raise ValueError("kpi_board: maximal 9 Kacheln") + seen: set[str] = set() + out: list[dict[str, str]] = [] + for item in tiles_raw: + tid = _normalize_kpi_tile_entry(item) + if tid in seen: + raise ValueError(f"kpi_board: doppelte Kachel-id {tid}") + seen.add(tid) + out.append({"id": tid}) + return {"tiles": out} + + +def _parse_chart_days(v: Any, label: str) -> int: + if isinstance(v, bool): + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + if isinstance(v, float): + if not math.isfinite(v): + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + if abs(v - round(v)) > 1e-9: + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + return int(round(v)) + if isinstance(v, int): + return v + raise ValueError(f"{label}: chart_days muss ganze Zahl sein") + + +def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: + allowed = frozenset({"chart_days"}) + unknown = set(raw) - allowed + if unknown: + raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}") + if "chart_days" not in raw: + return {} + v = _parse_chart_days(raw["chart_days"], label) + if v < 7 or v > 90: + raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen") + return {"chart_days": v} + + diff --git a/backend/dashboard_widget_entitlements.py b/backend/dashboard_widget_entitlements.py new file mode 100644 index 0000000..d1dd922 --- /dev/null +++ b/backend/dashboard_widget_entitlements.py @@ -0,0 +1,87 @@ +""" +Dashboard-Widgets × Feature-System: Sichtbarkeit aus check_feature_access. + +Katalog-Einträge optional `requires_feature` (features.id). Fehlt der Key → immer erlaubt. +""" +from __future__ import annotations + +import copy +from typing import Any + +from widget_catalog import WIDGET_CATALOG + + +def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict: + """Indirection für Tests (monkeypatch) und spätes Laden von auth (bcrypt).""" + from auth import check_feature_access + + return check_feature_access(profile_id, feature_id, conn) + +_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG} + + +def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool: + entry = _WIDGET_ENTRY_BY_ID.get(widget_id) + if entry is None: + return False + fid = entry.get("requires_feature") + if not fid: + return True + return bool(_check_feature_access(profile_id, fid, conn)["allowed"]) + + +def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]: + return { + "id": entry["id"], + "title": entry["title"], + "description": entry["description"], + "allowed": allowed, + } + + +def widgets_catalog_for_profile(profile_id: str, conn) -> list[dict[str, Any]]: + """Zeilen für GET /api/app/widgets/catalog (ohne internes requires_feature-Feld).""" + out: list[dict[str, Any]] = [] + for e in WIDGET_CATALOG: + fid = e.get("requires_feature") + allowed = True + if fid: + allowed = bool(_check_feature_access(profile_id, fid, conn)["allowed"]) + out.append(_public_row(e, allowed=allowed)) + return out + + +def widgets_catalog_payload(profile_id: str, conn) -> dict[str, Any]: + return { + "catalog_version": 1, + "widgets": widgets_catalog_for_profile(profile_id, conn), + } + + +def widgets_catalog_admin_payload() -> dict[str, Any]: + """Admin: alle Widgets als auswählbar (ohne Feature-Filter).""" + return { + "catalog_version": 1, + "widgets": [_public_row(e, allowed=True) for e in WIDGET_CATALOG], + } + + +def apply_entitlements_to_layout_dict(layout: dict[str, Any], profile_id: str, conn) -> dict[str, Any]: + """ + Setzt enabled=False für Widgets ohne Berechtigung. Mindestens ein Widget bleibt aktiv (welcome). + """ + out = copy.deepcopy(layout) + widgets = out.get("widgets") or [] + for w in widgets: + wid = w.get("id") + if not wid: + continue + if w.get("enabled") and not widget_id_allowed(wid, profile_id, conn): + w["enabled"] = False + if not any(w.get("enabled") for w in widgets): + for w in widgets: + if w.get("id") == "welcome": + w["enabled"] = True + break + return out + diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index 2742cde..dddcca9 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -19,6 +19,10 @@ 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`` + - reference_values: Profile reference value reads + summary (Layer 1) + Import: ``from data_layer import reference_values`` or submodule imports Phase 0c: Multi-Layer Architecture Version: 1.0 diff --git a/backend/data_layer/reference_values.py b/backend/data_layer/reference_values.py new file mode 100644 index 0000000..bc4f8b2 --- /dev/null +++ b/backend/data_layer/reference_values.py @@ -0,0 +1,179 @@ +""" +Reference values — Layer 1 (read paths + structured rows) + +Structured retrieval for profile reference values and the active type catalog. +Mutations (INSERT/UPDATE/DELETE) stay in routers/reference_values.py with validation. + +Dates are normalized to ISO strings; Decimals to float — suitable for JSON/API layers. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Optional + +from db import get_cursor, get_db, r2d + + +def normalize_reference_row(d: Optional[dict[str, Any]]) -> dict[str, Any]: + """Normalize DB row dict for JSON (dates → ISO, Decimal → float).""" + if not d: + return d + out = dict(d) + for k in ("effective_date", "created_at", "updated_at"): + v = out.get(k) + if v is not None and hasattr(v, "isoformat"): + out[k] = v.isoformat() + vn = out.get("value_numeric") + if vn is not None and isinstance(vn, Decimal): + out["value_numeric"] = float(vn) + return out + + +def fetch_reference_type_by_key(cur, key: str, require_active: bool = True) -> Optional[dict[str, Any]]: + """Single type row by key; for use inside router transactions (shared cursor).""" + q = ( + "SELECT id, key, label, description, default_unit, active, category, " + "value_data_type, validation_rules, metadata " + "FROM reference_value_types WHERE key = %s " + ) + if require_active: + q += "AND active = TRUE " + cur.execute(q, (key,)) + row = cur.fetchone() + return r2d(row) if row else None + + +def list_active_reference_value_types_data() -> list[dict[str, Any]]: + """All active reference_value_types rows, catalog sort order.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT + id, key, label, description, default_unit, sort_order, active, + category, value_data_type, validation_rules, metadata, created_at + FROM reference_value_types + WHERE active = TRUE + ORDER BY sort_order ASC, id ASC + """ + ) + return [normalize_reference_row(r2d(r)) for r in cur.fetchall()] + + +def list_profile_reference_values_for_type( + profile_id: str, type_key: str +) -> Optional[list[dict[str, Any]]]: + """ + Historical entries for one type, newest first. + Returns None if type_key does not resolve to an active type. + """ + with get_db() as conn: + cur = get_cursor(conn) + t = fetch_reference_type_by_key(cur, type_key, require_active=True) + if not t: + return None + 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 + """, + (profile_id, t["key"]), + ) + return [normalize_reference_row(r2d(r)) for r in cur.fetchall()] + + +def build_summary_tiles_from_ranked_rows(raw_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Build /summary.tile payloads from SQL rows (rn 1..2 per type). + Each row still contains rn, type_sort_order, value_data_type before stripping. + """ + by_key: dict[str, dict[str, Any]] = {} + skip_cols = frozenset({"rn", "type_sort_order", "value_data_type"}) + for row in raw_rows: + rn = int(row.get("rn") or 0) + key = row["type_key"] + if key not in by_key: + by_key[key] = { + "type_key": key, + "type_label": row.get("type_label") or key, + "value_data_type": (row.get("value_data_type") or "decimal").strip().lower(), + "sort_key": (row.get("type_sort_order") or 0, key), + "latest": None, + "previous": None, + } + entry = {k: v for k, v in row.items() if k not in skip_cols} + api_entry = normalize_reference_row(entry) + if rn == 1: + by_key[key]["latest"] = api_entry + elif rn == 2: + by_key[key]["previous"] = api_entry + + tiles = sorted(by_key.values(), key=lambda t: t["sort_key"]) + for t in tiles: + t.pop("sort_key", None) + return tiles + + +def get_profile_reference_values_summary(profile_id: str) -> dict[str, Any]: + """latest + previous entry per type (active types only), tiles sorted like catalog.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + WITH ranked AS ( + 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, + rt.sort_order AS type_sort_order, + rt.value_data_type, + ROW_NUMBER() OVER ( + PARTITION BY v.reference_value_type_id + ORDER BY v.effective_date DESC, v.created_at DESC + ) AS rn + 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.active = TRUE + ) + SELECT * FROM ranked WHERE rn <= 2 + ORDER BY type_sort_order ASC, type_key ASC, rn ASC + """, + (profile_id,), + ) + raw_rows = [r2d(r) for r in cur.fetchall()] + + tiles = build_summary_tiles_from_ranked_rows(raw_rows) + return {"tiles": tiles} diff --git a/backend/data_layer/training_profile/__init__.py b/backend/data_layer/training_profile/__init__.py new file mode 100644 index 0000000..913090f --- /dev/null +++ b/backend/data_layer/training_profile/__init__.py @@ -0,0 +1,36 @@ +""" +Training profile resolver (Layer 1 scaffold). + +Template-driven multi-dimensional evaluation with built-in algorithms and +Focus Area contribution aggregation. Import explicitly from this package. + +Public API: + - resolve_training_evaluation + - resolve_for_base_profile + - models: CalculationTemplate, TrainingEvaluationResult, ... + - registries: templates, profiles, algorithms +""" + +from data_layer.training_profile.models import ( + CalculationTemplate, + DimensionResult, + DimensionSpec, + FocusAreaMapping, + TrainingBaseProfile, + TrainingEvaluationResult, +) +from data_layer.training_profile.resolver import ( + resolve_for_base_profile, + resolve_training_evaluation, +) + +__all__ = [ + "CalculationTemplate", + "DimensionResult", + "DimensionSpec", + "FocusAreaMapping", + "TrainingBaseProfile", + "TrainingEvaluationResult", + "resolve_for_base_profile", + "resolve_training_evaluation", +] diff --git a/backend/data_layer/training_profile/algorithms/__init__.py b/backend/data_layer/training_profile/algorithms/__init__.py new file mode 100644 index 0000000..2748a00 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/__init__.py @@ -0,0 +1,13 @@ +"""Built-in training evaluation algorithms (code-defined only).""" + +from .registry import ( + get_algorithm, + list_algorithm_ids, + register_algorithm, +) + +__all__ = [ + "get_algorithm", + "list_algorithm_ids", + "register_algorithm", +] diff --git a/backend/data_layer/training_profile/algorithms/base.py b/backend/data_layer/training_profile/algorithms/base.py new file mode 100644 index 0000000..41c9b0d --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/base.py @@ -0,0 +1,23 @@ +""" +Algorithm protocol: fixed implementations selected by id from declarative templates. +""" + +from __future__ import annotations + +from typing import Any, Dict, Mapping, Protocol + +from data_layer.training_profile.models import AlgorithmRunResult + + +class TrainingAlgorithm(Protocol): + """Built-in algorithm callable shape.""" + + id: str + + def __call__( + self, + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], + ) -> AlgorithmRunResult: + ... diff --git a/backend/data_layer/training_profile/algorithms/builtin/__init__.py b/backend/data_layer/training_profile/algorithms/builtin/__init__.py new file mode 100644 index 0000000..af52967 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/__init__.py @@ -0,0 +1,6 @@ +"""Example built-in algorithms (threshold bands, linear range mapping).""" + +from .linear_range import linear_range_score +from .threshold_band import threshold_band_score + +__all__ = ["linear_range_score", "threshold_band_score"] diff --git a/backend/data_layer/training_profile/algorithms/builtin/linear_range.py b/backend/data_layer/training_profile/algorithms/builtin/linear_range.py new file mode 100644 index 0000000..c0186a0 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/linear_range.py @@ -0,0 +1,69 @@ +""" +linear_range: map a value linearly from [min_value, max_value] to [0, 1], clamped. + +params: + value_key: str + min_value: float + max_value: float + invert: bool (optional) — if True, high values map to 0 + +Missing value → score 0 and missing_inputs. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.utils import safe_float + + +ALGORITHM_ID = "linear_range" + + +def linear_range_score( + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], +) -> AlgorithmRunResult: + value_key = str(params.get("value_key", "")) + if not value_key: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=["__param_value_key__"], + detail={"error": "params.value_key required"}, + ) + + if value_key not in inputs or inputs[value_key] is None: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=[value_key], + detail={"value_key": value_key}, + ) + + raw = safe_float(inputs[value_key]) + lo = safe_float(params.get("min_value")) + hi = safe_float(params.get("max_value")) + invert = bool(params.get("invert", False)) + + if hi <= lo: + return AlgorithmRunResult( + raw_score=raw, + normalized_score=0.0, + missing_inputs=[], + detail={"error": "max_value must be > min_value", "min": lo, "max": hi}, + ) + + t = (raw - lo) / (hi - lo) + t = max(0.0, min(1.0, t)) + if invert: + t = 1.0 - t + + return AlgorithmRunResult( + raw_score=raw, + normalized_score=t, + missing_inputs=[], + detail={"value_key": value_key, "min": lo, "max": hi, "invert": invert}, + ) diff --git a/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py b/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py new file mode 100644 index 0000000..4659e8f --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/builtin/threshold_band.py @@ -0,0 +1,75 @@ +""" +threshold_band: map a numeric value into a score using ordered upper bounds. + +params: + value_key: str — key in inputs to read + bands: list of { "max": float | null, "score": float } — ascending max; null = +inf + +Missing value_key → normalized_score 0, missing_inputs lists value_key. +""" + +from __future__ import annotations + +from typing import Any, List, Mapping + +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.utils import safe_float + + +ALGORITHM_ID = "threshold_band" + + +def threshold_band_score( + *, + inputs: Mapping[str, Any], + params: Mapping[str, Any], +) -> AlgorithmRunResult: + value_key = str(params.get("value_key", "")) + if not value_key: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=["__param_value_key__"], + detail={"error": "params.value_key required"}, + ) + + if value_key not in inputs or inputs[value_key] is None: + return AlgorithmRunResult( + raw_score=0.0, + normalized_score=0.0, + missing_inputs=[value_key], + detail={"value_key": value_key}, + ) + + raw = safe_float(inputs[value_key]) + bands = params.get("bands") or [] + if not isinstance(bands, list) or not bands: + return AlgorithmRunResult( + raw_score=raw, + normalized_score=0.0, + missing_inputs=[], + detail={"error": "params.bands must be a non-empty list", "raw": raw}, + ) + + score = 0.0 + for band in bands: + if not isinstance(band, dict): + continue + max_v = band.get("max") + s = safe_float(band.get("score")) + if max_v is None: + score = s + break + if raw <= safe_float(max_v): + score = s + break + else: + score = safe_float(bands[-1].get("score")) if isinstance(bands[-1], dict) else 0.0 + + norm = max(0.0, min(1.0, score)) + return AlgorithmRunResult( + raw_score=raw, + normalized_score=norm, + missing_inputs=[], + detail={"value_key": value_key, "bands_applied": True, "band_score": score}, + ) diff --git a/backend/data_layer/training_profile/algorithms/registry.py b/backend/data_layer/training_profile/algorithms/registry.py new file mode 100644 index 0000000..052e472 --- /dev/null +++ b/backend/data_layer/training_profile/algorithms/registry.py @@ -0,0 +1,47 @@ +""" +Registry for built-in algorithm ids → callables. + +Only code registers algorithms; templates reference ids declared here. +""" + +from __future__ import annotations + +from typing import Any, Callable, Dict, Mapping + +from data_layer.training_profile.algorithms.builtin.linear_range import ( + ALGORITHM_ID as LINEAR_RANGE_ID, + linear_range_score, +) +from data_layer.training_profile.algorithms.builtin.threshold_band import ( + ALGORITHM_ID as THRESHOLD_BAND_ID, + threshold_band_score, +) +from data_layer.training_profile.models import AlgorithmRunResult + +AlgorithmFn = Callable[..., AlgorithmRunResult] + +_REGISTRY: Dict[str, AlgorithmFn] = {} + + +def register_algorithm(algorithm_id: str, fn: AlgorithmFn) -> None: + if algorithm_id in _REGISTRY: + raise ValueError(f"Algorithm already registered: {algorithm_id}") + _REGISTRY[algorithm_id] = fn + + +def get_algorithm(algorithm_id: str) -> AlgorithmFn: + if algorithm_id not in _REGISTRY: + raise KeyError(f"Unknown algorithm_id: {algorithm_id}") + return _REGISTRY[algorithm_id] + + +def list_algorithm_ids() -> tuple[str, ...]: + return tuple(sorted(_REGISTRY.keys())) + + +def _register_defaults() -> None: + register_algorithm(THRESHOLD_BAND_ID, threshold_band_score) + register_algorithm(LINEAR_RANGE_ID, linear_range_score) + + +_register_defaults() diff --git a/backend/data_layer/training_profile/models.py b/backend/data_layer/training_profile/models.py new file mode 100644 index 0000000..2d4c58b --- /dev/null +++ b/backend/data_layer/training_profile/models.py @@ -0,0 +1,121 @@ +""" +Declarative schemas for template-based training evaluation (Layer 1 scaffold). + +Templates select built-in algorithms by id and pass parameters; they do not embed +executable logic. See resolver and algorithm registry. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional + + +@dataclass(frozen=True) +class FocusAreaMapping: + """Maps a share of one dimension's score to a Focus Area (by stable key).""" + + focus_area_key: str + weight: float + + +@dataclass(frozen=True) +class DimensionSpec: + """ + One evaluation dimension: algorithm + inputs + params + FA mapping. + + inputs: names of keys expected in the flat activity_inputs dict passed to the resolver. + """ + + key: str + algorithm_id: str + inputs: tuple[str, ...] + params: Mapping[str, Any] + maps_to: tuple[FocusAreaMapping, ...] + + +@dataclass(frozen=True) +class CalculationTemplate: + """Declarative multi-dimensional calculation template (in-code registry).""" + + id: str + version: str + label: str + dimensions: tuple[DimensionSpec, ...] + + +@dataclass(frozen=True) +class TrainingBaseProfile: + """ + Conceptual base profile: links a training context to a default template. + + allowed_dimension_keys: if set, dimensions not listed are skipped at resolve time. + """ + + key: str + label: str + default_template_id: str + allowed_dimension_keys: Optional[frozenset[str]] = None + + +@dataclass +class AlgorithmRunResult: + """Output of a single built-in algorithm execution.""" + + raw_score: float + normalized_score: float + missing_inputs: List[str] + detail: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DimensionResult: + """Per-dimension result after resolution.""" + + dimension_key: str + algorithm_id: str + raw_score: float + normalized_score: float + missing_inputs: List[str] + evidence: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class TrainingEvaluationResult: + """ + Stable structured result for Layer 2 (KI, charts, APIs, future persistence). + + focus_area_contributions: aggregated contribution per focus_area key (additive). + """ + + template_id: str + template_version: str + base_profile_key: Optional[str] + dimension_results: List[DimensionResult] + focus_area_contributions: Dict[str, float] + confidence: str + evidence: Dict[str, Any] + trace: Optional[Dict[str, Any]] = None + + def to_serializable(self) -> Dict[str, Any]: + """JSON-compatible dict (for APIs / storage).""" + return { + "template_id": self.template_id, + "template_version": self.template_version, + "base_profile_key": self.base_profile_key, + "dimension_results": [ + { + "dimension_key": d.dimension_key, + "algorithm_id": d.algorithm_id, + "raw_score": d.raw_score, + "normalized_score": d.normalized_score, + "missing_inputs": d.missing_inputs, + "evidence": d.evidence, + } + for d in self.dimension_results + ], + "focus_area_contributions": dict(self.focus_area_contributions), + "confidence": self.confidence, + "evidence": self.evidence, + "trace": self.trace, + } diff --git a/backend/data_layer/training_profile/profiles/__init__.py b/backend/data_layer/training_profile/profiles/__init__.py new file mode 100644 index 0000000..ee61213 --- /dev/null +++ b/backend/data_layer/training_profile/profiles/__init__.py @@ -0,0 +1,13 @@ +"""Training base profile registrations (scaffold, in-code).""" + +from .registry import ( + get_training_base_profile, + list_training_base_profile_keys, + try_get_training_base_profile, +) + +__all__ = [ + "get_training_base_profile", + "list_training_base_profile_keys", + "try_get_training_base_profile", +] diff --git a/backend/data_layer/training_profile/profiles/registry.py b/backend/data_layer/training_profile/profiles/registry.py new file mode 100644 index 0000000..269212e --- /dev/null +++ b/backend/data_layer/training_profile/profiles/registry.py @@ -0,0 +1,52 @@ +""" +Example training base profiles — point to default templates and optional dimension filters. + +Future: DB-backed training types may reference profile keys; this registry is code-only. +""" + +from __future__ import annotations + +from typing import Dict, Optional + +from data_layer.training_profile.models import TrainingBaseProfile + +_PROFILES: Dict[str, TrainingBaseProfile] = {} + + +def _register(p: TrainingBaseProfile) -> None: + if p.key in _PROFILES: + raise ValueError(f"Duplicate training base profile key: {p.key}") + _PROFILES[p.key] = p + + +def get_training_base_profile(key: str) -> TrainingBaseProfile: + if key not in _PROFILES: + raise KeyError(f"Unknown training base profile: {key}") + return _PROFILES[key] + + +def try_get_training_base_profile(key: str) -> Optional[TrainingBaseProfile]: + return _PROFILES.get(key) + + +def list_training_base_profile_keys() -> tuple[str, ...]: + return tuple(sorted(_PROFILES.keys())) + + +_register( + TrainingBaseProfile( + key="scaffold_aerobic_base", + label="Scaffold: aerobic base profile", + default_template_id="scaffold_example_aerobic_v1", + allowed_dimension_keys=None, + ) +) + +_register( + TrainingBaseProfile( + key="scaffold_strength_base", + label="Scaffold: strength base profile", + default_template_id="scaffold_example_strength_v1", + allowed_dimension_keys=frozenset({"effort"}), + ) +) diff --git a/backend/data_layer/training_profile/resolver.py b/backend/data_layer/training_profile/resolver.py new file mode 100644 index 0000000..98d441d --- /dev/null +++ b/backend/data_layer/training_profile/resolver.py @@ -0,0 +1,160 @@ +""" +Layer 1 entry: resolve a multi-dimensional training evaluation from a template. + +Pure calculation orchestration — no DB, no HTTP, no formatting for KI/charts. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, List, Mapping, Optional + +from data_layer.training_profile.algorithms.registry import get_algorithm +from data_layer.training_profile.models import ( + CalculationTemplate, + DimensionResult, + DimensionSpec, + TrainingBaseProfile, + TrainingEvaluationResult, +) + + +def _required_inputs_present( + activity_inputs: Mapping[str, Any], keys: tuple[str, ...] +) -> tuple[bool, List[str]]: + missing: List[str] = [] + for k in keys: + if k not in activity_inputs or activity_inputs[k] is None: + missing.append(k) + return (len(missing) == 0, missing) + + +def _confidence_level(total_dims: int, dims_with_any_missing: int) -> str: + if total_dims == 0: + return "insufficient" + if dims_with_any_missing == 0: + return "high" + if dims_with_any_missing >= total_dims: + return "insufficient" + if dims_with_any_missing == 1: + return "medium" + return "low" + + +def _filter_dimensions( + template: CalculationTemplate, base_profile: Optional[TrainingBaseProfile] +) -> tuple[DimensionSpec, ...]: + if base_profile is None or base_profile.allowed_dimension_keys is None: + return template.dimensions + allowed = base_profile.allowed_dimension_keys + return tuple(d for d in template.dimensions if d.key in allowed) + + +def resolve_training_evaluation( + *, + activity_inputs: Mapping[str, Any], + template: CalculationTemplate, + base_profile: Optional[TrainingBaseProfile] = None, + include_trace: bool = False, +) -> TrainingEvaluationResult: + """ + Run all template dimensions, aggregate Focus Area contributions, attach evidence. + + activity_inputs: flat dict (e.g. avg_hr, duration_min, distance_km) supplied by caller. + """ + dimensions = _filter_dimensions(template, base_profile) + dimension_results: List[DimensionResult] = [] + contributions: Dict[str, float] = defaultdict(float) + evidence: Dict[str, Any] = { + "dimensions_total": len(dimensions), + "inputs_keys": sorted(activity_inputs.keys()), + } + trace: Optional[Dict[str, Any]] = {} if include_trace else None + + dims_with_missing = 0 + + for spec in dimensions: + ok, missing = _required_inputs_present(activity_inputs, spec.inputs) + if not ok: + dims_with_missing += 1 + dimension_results.append( + DimensionResult( + dimension_key=spec.key, + algorithm_id=spec.algorithm_id, + raw_score=0.0, + normalized_score=0.0, + missing_inputs=list(missing), + evidence={"skipped": True, "reason": "required_inputs_missing"}, + ) + ) + if trace is not None: + trace[spec.key] = {"skipped": True, "missing": missing} + continue + + algo = get_algorithm(spec.algorithm_id) + slice_inputs = {k: activity_inputs[k] for k in spec.inputs} + run = algo(inputs=slice_inputs, params=dict(spec.params)) + + if run.missing_inputs: + dims_with_missing += 1 + + dimension_results.append( + DimensionResult( + dimension_key=spec.key, + algorithm_id=spec.algorithm_id, + raw_score=run.raw_score, + normalized_score=run.normalized_score, + missing_inputs=list(run.missing_inputs), + evidence={"algorithm_detail": run.detail}, + ) + ) + + for m in spec.maps_to: + contributions[m.focus_area_key] += run.normalized_score * m.weight + + if trace is not None: + trace[spec.key] = { + "inputs": dict(slice_inputs), + "params": dict(spec.params), + "run": { + "raw_score": run.raw_score, + "normalized_score": run.normalized_score, + "missing_inputs": run.missing_inputs, + "detail": run.detail, + }, + "maps_to": [(x.focus_area_key, x.weight) for x in spec.maps_to], + } + + conf = _confidence_level(len(dimensions), dims_with_missing) + evidence["dimensions_with_missing_or_failed"] = dims_with_missing + + return TrainingEvaluationResult( + template_id=template.id, + template_version=template.version, + base_profile_key=base_profile.key if base_profile else None, + dimension_results=dimension_results, + focus_area_contributions=dict(contributions), + confidence=conf, + evidence=evidence, + trace=trace, + ) + + +def resolve_for_base_profile( + *, + activity_inputs: Mapping[str, Any], + base_profile_key: str, + include_trace: bool = False, +) -> TrainingEvaluationResult: + """Convenience: load profile + default template from registries.""" + from data_layer.training_profile.profiles.registry import get_training_base_profile + from data_layer.training_profile.templates.registry import get_calculation_template + + profile = get_training_base_profile(base_profile_key) + template = get_calculation_template(profile.default_template_id) + return resolve_training_evaluation( + activity_inputs=activity_inputs, + template=template, + base_profile=profile, + include_trace=include_trace, + ) diff --git a/backend/data_layer/training_profile/templates/__init__.py b/backend/data_layer/training_profile/templates/__init__.py new file mode 100644 index 0000000..5081da5 --- /dev/null +++ b/backend/data_layer/training_profile/templates/__init__.py @@ -0,0 +1,5 @@ +"""Declarative calculation templates (in-code registry, scaffold).""" + +from .registry import get_calculation_template, list_calculation_template_ids + +__all__ = ["get_calculation_template", "list_calculation_template_ids"] diff --git a/backend/data_layer/training_profile/templates/registry.py b/backend/data_layer/training_profile/templates/registry.py new file mode 100644 index 0000000..bdaeb0a --- /dev/null +++ b/backend/data_layer/training_profile/templates/registry.py @@ -0,0 +1,96 @@ +""" +Example calculation templates — declarative only; algorithms are referenced by id. + +These are scaffolding examples, not production coaching rules. +""" + +from __future__ import annotations + +from typing import Dict + +from data_layer.training_profile.models import CalculationTemplate, DimensionSpec, FocusAreaMapping + +_TEMPLATES: Dict[str, CalculationTemplate] = {} + + +def _register(t: CalculationTemplate) -> None: + if t.id in _TEMPLATES: + raise ValueError(f"Duplicate template id: {t.id}") + _TEMPLATES[t.id] = t + + +def get_calculation_template(template_id: str) -> CalculationTemplate: + if template_id not in _TEMPLATES: + raise KeyError(f"Unknown calculation template: {template_id}") + return _TEMPLATES[template_id] + + +def list_calculation_template_ids() -> tuple[str, ...]: + return tuple(sorted(_TEMPLATES.keys())) + + +# --- Example templates (illustrative) --- + +_example_aerobic = CalculationTemplate( + id="scaffold_example_aerobic_v1", + version="1", + label="Scaffold: aerobic-style intensity + volume (example)", + dimensions=( + DimensionSpec( + key="intensity", + algorithm_id="threshold_band", + inputs=("avg_hr", "duration_min"), + params={ + "value_key": "avg_hr", + "bands": [ + {"max": 120, "score": 0.25}, + {"max": 140, "score": 0.55}, + {"max": 160, "score": 0.85}, + {"max": None, "score": 1.0}, + ], + }, + maps_to=( + FocusAreaMapping("aerobic_endurance", 0.7), + FocusAreaMapping("cardiovascular_health", 0.3), + ), + ), + DimensionSpec( + key="volume", + algorithm_id="linear_range", + inputs=("duration_min", "distance_km"), + params={ + "value_key": "duration_min", + "min_value": 10.0, + "max_value": 90.0, + "invert": False, + }, + maps_to=(FocusAreaMapping("aerobic_endurance", 0.8),), + ), + ), +) + +_example_strength = CalculationTemplate( + id="scaffold_example_strength_v1", + version="1", + label="Scaffold: strength session load (example)", + dimensions=( + DimensionSpec( + key="effort", + algorithm_id="linear_range", + inputs=("duration_min",), + params={ + "value_key": "duration_min", + "min_value": 20.0, + "max_value": 75.0, + "invert": False, + }, + maps_to=( + FocusAreaMapping("strength", 0.9), + FocusAreaMapping("strength_endurance", 0.1), + ), + ), + ), +) + +_register(_example_aerobic) +_register(_example_strength) diff --git a/backend/focus_area_usage_helpers.py b/backend/focus_area_usage_helpers.py new file mode 100644 index 0000000..52d5695 --- /dev/null +++ b/backend/focus_area_usage_helpers.py @@ -0,0 +1,20 @@ +"""Kleine Helfer für Focus-Area-Nutzungstypen (ohne Router-/Auth-Abhängigkeiten).""" +from __future__ import annotations + +import json +from typing import Any, List + + +def coerce_usage_type_keys(raw: Any) -> List[str]: + """json_agg / JSON-Spalte zu list[str] normalisieren.""" + if raw is None: + return [] + if isinstance(raw, list): + return [str(x) for x in raw] + if isinstance(raw, str): + try: + data = json.loads(raw) + return [str(x) for x in data] if isinstance(data, list) else [] + except json.JSONDecodeError: + return [] + return [] diff --git a/backend/main.py b/backend/main.py index 22d2717..5ad6444 100644 --- a/backend/main.py +++ b/backend/main.py @@ -28,6 +28,9 @@ 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) +from routers import admin_reference_value_types # Admin: Referenzwert-Typen +from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout # ── App Configuration ───────────────────────────────────────────────────────── DATA_DIR = Path(os.getenv("DATA_DIR", "./data")) @@ -115,6 +118,9 @@ 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 +app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types +app.include_router(app_dashboard.router) # /api/app/dashboard-layout # ── Health Check ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/backend/migrations/036_focus_area_usage_types.sql b/backend/migrations/036_focus_area_usage_types.sql new file mode 100644 index 0000000..566fa68 --- /dev/null +++ b/backend/migrations/036_focus_area_usage_types.sql @@ -0,0 +1,36 @@ +-- Migration 036: Focus Area — erlaubte Nutzungstypen (Referenz + M:N) +-- Date: 2026-04-06 +-- Purpose: System-seeded usage types; optional Zuordnung pro Focus Area (kein Auto-Backfill) + +-- Referenztabelle: feste, systemdefinierte Nutzungstypen +CREATE TABLE IF NOT EXISTS focus_area_usage_types ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(64) UNIQUE NOT NULL, + label_de VARCHAR(160), + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE focus_area_usage_types IS + 'Systemdefinierte Nutzungsarten für Focus Areas (kein Admin-CRUD in v1)'; + +-- M:N: welche Nutzungstypen für eine Focus Area erlaubt sind (leer = noch nicht klassifiziert) +CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types ( + focus_area_id UUID NOT NULL REFERENCES focus_area_definitions(id) ON DELETE CASCADE, + usage_type_id UUID NOT NULL REFERENCES focus_area_usage_types(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + PRIMARY KEY (focus_area_id, usage_type_id) +); + +CREATE INDEX IF NOT EXISTS idx_fadut_focus_area ON focus_area_definition_usage_types(focus_area_id); +CREATE INDEX IF NOT EXISTS idx_fadut_usage_type ON focus_area_definition_usage_types(usage_type_id); + +COMMENT ON TABLE focus_area_definition_usage_types IS + 'Zuordnung Focus Area → erlaubte Nutzungstypen (kein automatisches Befüllen bestehender Areas)'; + +-- Seed: nur die drei Typen — keine Zeilen in der Junction-Tabelle +INSERT INTO focus_area_usage_types (key, label_de, sort_order) VALUES + ('goal_priority', 'Ziele / Prioritäten', 1), + ('expected_training_effect', 'Erwartetes Trainingseffekt-Profil', 2), + ('concrete_training_contribution', 'Konkrete Trainings-Beiträge / Belastungsausprägung', 3) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/037_profile_reference_values.sql b/backend/migrations/037_profile_reference_values.sql new file mode 100644 index 0000000..a134ad5 --- /dev/null +++ b/backend/migrations/037_profile_reference_values.sql @@ -0,0 +1,99 @@ +-- Migration 037: Persönliche Referenzwerte (Typkatalog + historische Werte pro Profil) +-- Date: 2026-04-06 +-- Purpose: System-definierte Referenztyp-Schlüssel; Nutzer pflegt nur historische Einträge. + +CREATE TABLE IF NOT EXISTS reference_value_types ( + id SERIAL PRIMARY KEY, + key VARCHAR(64) NOT NULL UNIQUE, + label VARCHAR(200) NOT NULL, + description TEXT, + default_unit VARCHAR(32), + sort_order INT NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE reference_value_types IS + 'Systemdefinierte Typen persönlicher Referenzwerte (kein Nutzer-CRUD auf Typen)'; + +CREATE TABLE IF NOT EXISTS profile_reference_values ( + id BIGSERIAL PRIMARY KEY, + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + reference_value_type_id INT NOT NULL REFERENCES reference_value_types(id) ON DELETE RESTRICT, + effective_date DATE NOT NULL, + value_numeric NUMERIC(18, 6), + value_text TEXT, + unit VARCHAR(32) NOT NULL, + source TEXT, + confidence NUMERIC(5, 2), + method TEXT, + notes TEXT, + extra JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + CONSTRAINT profile_reference_values_value_ck CHECK ( + value_numeric IS NOT NULL OR (value_text IS NOT NULL AND length(trim(value_text)) > 0) + ) +); + +COMMENT ON TABLE profile_reference_values IS + 'Historische Referenzwerte pro Profil (kein Überschreiben eines Einzel-»aktuellen« Werts)'; + +CREATE INDEX IF NOT EXISTS idx_prv_profile_type_date + ON profile_reference_values (profile_id, reference_value_type_id, effective_date DESC); + +CREATE INDEX IF NOT EXISTS idx_prv_profile + ON profile_reference_values (profile_id); + +-- Seed: nur Typdefinitionen, keine Benutzerwerte +INSERT INTO reference_value_types (key, label, description, default_unit, sort_order, active) VALUES + ( + 'max_heart_rate', + 'Maximale Herzfrequenz', + 'Individuelle HRmax (z. B. aus Leistungstest oder geschätzt).', + 'bpm', + 10, + TRUE + ), + ( + 'anaerobic_threshold_hr', + 'Anaerober Schwellenwert (Herzfrequenz)', + 'Laktatschwelle / anaerober Schwellenpuls.', + 'bpm', + 20, + TRUE + ), + ( + 'aerobic_threshold_hr', + 'Aerober Schwellenwert (Herzfrequenz)', + 'Erster aerobet/schwellenanaloger Trainingsbereich (GA2).', + 'bpm', + 30, + TRUE + ), + ( + 'training_frequency_weekly', + 'Trainingshäufigkeit', + 'Geplante oder beobachtete Einheiten pro Woche.', + 'Sessions/Woche', + 40, + TRUE + ), + ( + 'fitness_level', + 'Fitnesslevel', + 'Subjektive oder normierte Einstufung (Zahl oder Kurzbeschreibung im Freitextfeld).', + 'Stufe', + 50, + TRUE + ), + ( + 'resting_heart_rate', + 'Ruhepuls (Referenz)', + 'Ruheherzfrequenz als persönliche Referenz (z. B. morgens).', + 'bpm', + 15, + TRUE + ) +ON CONFLICT (key) DO NOTHING; diff --git a/backend/migrations/038_reference_value_type_metadata.sql b/backend/migrations/038_reference_value_type_metadata.sql new file mode 100644 index 0000000..07464a3 --- /dev/null +++ b/backend/migrations/038_reference_value_type_metadata.sql @@ -0,0 +1,54 @@ +-- Migration 038: Referenzwert-Typen — Kategorie, Datentyp, Plausibilisierung; confidence als Diskretwert +-- Date: 2026-04-06 + +ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS category TEXT; + +ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS value_data_type VARCHAR(32) NOT NULL DEFAULT 'decimal'; + +ALTER TABLE reference_value_types ADD COLUMN IF NOT EXISTS validation_rules JSONB NOT NULL DEFAULT '{}'; + +ALTER TABLE reference_value_types DROP CONSTRAINT IF EXISTS rvt_value_data_type_chk; + +ALTER TABLE reference_value_types ADD CONSTRAINT rvt_value_data_type_chk CHECK ( + value_data_type IN ('integer', 'decimal', 'percentage', 'text', 'enum') +); + +COMMENT ON COLUMN reference_value_types.category IS 'Freitext-Kategorie (UI/Admin)'; +COMMENT ON COLUMN reference_value_types.value_data_type IS 'Logischer Wert-Typ für Erfassung & Validierung'; +COMMENT ON COLUMN reference_value_types.validation_rules IS 'JSON: min, max, positive_only, max_length, not_empty, allowed_values[]'; + +-- Bestehende Seeds mit sinnvollen Defaults +UPDATE reference_value_types SET + value_data_type = 'integer', + validation_rules = '{"min": 40, "max": 240, "positive_only": true}'::jsonb +WHERE key IN ('max_heart_rate', 'resting_heart_rate'); + +UPDATE reference_value_types SET + value_data_type = 'integer', + validation_rules = '{"min": 80, "max": 210, "positive_only": true}'::jsonb +WHERE key IN ('anaerobic_threshold_hr', 'aerobic_threshold_hr'); + +UPDATE reference_value_types SET + value_data_type = 'integer', + validation_rules = '{"min": 0, "max": 21, "positive_only": false}'::jsonb +WHERE key = 'training_frequency_weekly'; + +UPDATE reference_value_types SET + value_data_type = 'text', + validation_rules = '{"max_length": 200, "not_empty": true}'::jsonb +WHERE key = 'fitness_level'; + +-- profile_reference_values.confidence: von NUMERIC auf diskrete Stufen +ALTER TABLE profile_reference_values ALTER COLUMN confidence DROP DEFAULT; + +ALTER TABLE profile_reference_values + ALTER COLUMN confidence TYPE VARCHAR(32) + USING (NULL::varchar(32)); + +COMMENT ON COLUMN profile_reference_values.confidence IS 'high | medium | low | unknown'; + +-- Optionaler leerer Text (not_empty=false): leere Zeichenkette statt „kein Wert“ +ALTER TABLE profile_reference_values DROP CONSTRAINT IF EXISTS profile_reference_values_value_ck; +ALTER TABLE profile_reference_values ADD CONSTRAINT profile_reference_values_value_ck CHECK ( + value_numeric IS NOT NULL OR value_text IS NOT NULL +); diff --git a/backend/migrations/039_dashboard_layout.sql b/backend/migrations/039_dashboard_layout.sql new file mode 100644 index 0000000..5dc6772 --- /dev/null +++ b/backend/migrations/039_dashboard_layout.sql @@ -0,0 +1,5 @@ +-- Nutzer-Dashboard: Layout (JSONB) für geschützten App-Lab-Bereich (Issue #65, Phase 1) +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS dashboard_layout JSONB DEFAULT NULL; + +COMMENT ON COLUMN profiles.dashboard_layout IS + 'Optional: konfigurierbare Dashboard-Widget-Reihenfolge/Sichtbarkeit (v1 JSON). NULL = Standard.'; diff --git a/backend/migrations/040_system_config.sql b/backend/migrations/040_system_config.sql new file mode 100644 index 0000000..b452cfa --- /dev/null +++ b/backend/migrations/040_system_config.sql @@ -0,0 +1,8 @@ +-- Globale System-Konfiguration (Key/Value, JSONB) +CREATE TABLE IF NOT EXISTS system_config ( + key VARCHAR(64) PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_system_config_updated_at ON system_config (updated_at); diff --git a/backend/reference_value_validation.py b/backend/reference_value_validation.py new file mode 100644 index 0000000..5bb1ebf --- /dev/null +++ b/backend/reference_value_validation.py @@ -0,0 +1,170 @@ +""" +Validierung & Konstanten für persönliche Referenzwerte. +""" +from __future__ import annotations + +from typing import Any, Optional + +from fastapi import HTTPException + +REF_VALUE_SOURCES = frozenset( + { + "manual_user", + "manual_admin", + "import_device", + "import_app", + "derived_system", + "estimated_system", + "test_entry", + } +) + +REF_VALUE_METHODS = frozenset( + { + "direct_measurement", + "lab_test", + "field_test", + "questionnaire", + "formula_estimation", + "trend_analysis", + "device_algorithm", + "manual_assessment", + "imported_external", + "unknown", + } +) + + +REF_VALUE_CONFIDENCE = frozenset({"high", "medium", "low", "unknown"}) + +# Anzeigereihenfolge (nicht alphabetisch) +REF_VALUE_CONFIDENCE_ORDER = ("high", "medium", "low", "unknown") + +VALUE_DATA_TYPES = frozenset({"integer", "decimal", "percentage", "text", "enum"}) + + +def _rules_dict(raw: Any) -> dict: + if not raw: + return {} + if isinstance(raw, dict): + return raw + return {} + + +def validate_meta_source(source: Optional[str]) -> str: + if not source or not str(source).strip(): + raise HTTPException(400, "Quelle (source) ist erforderlich.") + s = str(source).strip() + if s not in REF_VALUE_SOURCES: + raise HTTPException(400, f"Ungültige Quelle: {s}") + return s + + +def validate_meta_method(method: Optional[str]) -> str: + if not method or not str(method).strip(): + raise HTTPException(400, "Methode (method) ist erforderlich.") + m = str(method).strip() + if m not in REF_VALUE_METHODS: + raise HTTPException(400, f"Ungültige Methode: {m}") + return m + + +def validate_meta_confidence(confidence: Optional[str]) -> str: + if not confidence or not str(confidence).strip(): + raise HTTPException(400, "Vertrauensgrad (confidence) ist erforderlich.") + c = str(confidence).strip().lower() + if c not in REF_VALUE_CONFIDENCE: + raise HTTPException(400, f"Ungültiger Vertrauensgrad: {c}") + return c + + +def resolve_unit_from_type(default_unit: Optional[str]) -> str: + u = (default_unit or "").strip() + if not u: + raise HTTPException( + 400, + "Für diesen Kennwert-Typ ist keine Einheit hinterlegt. Bitte im Admin einen Standard unter „Standard-Einheit“ setzen.", + ) + return u + + +def validate_value_for_data_type( + value_data_type: str, + validation_rules_raw: Any, + value_numeric: Optional[float], + value_text: Optional[str], +) -> tuple[Optional[float], Optional[str]]: + """ + Je nach value_data_type den Wert prüfen und (value_numeric, value_text) für die DB liefern. + """ + vdt = (value_data_type or "decimal").strip().lower() + if vdt not in VALUE_DATA_TYPES: + raise HTTPException(400, f"Ungültiger interner Datentyp: {vdt}") + + rules = _rules_dict(validation_rules_raw) + + if vdt in ("integer", "decimal", "percentage"): + if value_numeric is None: + raise HTTPException(400, "Bitte einen numerischen Wert eingeben.") + v = float(value_numeric) + if vdt == "integer": + if abs(v - round(v)) > 1e-9: + raise HTTPException(400, "Der Wert muss eine ganze Zahl sein.") + v = float(int(round(v))) + pos = bool(rules.get("positive_only")) + mn = rules.get("min") + mx = rules.get("max") + if mn is not None: + mn = float(mn) + if mx is not None: + mx = float(mx) + if vdt == "percentage": + gmn = float(mn) if mn is not None else 0.0 + gmx = float(mx) if mx is not None else 100.0 + gmn = max(gmn, 0.0) + gmx = min(gmx, 100.0) + if gmn > gmx: + raise HTTPException(500, "Ungültige Plausibilisierung: min > max (Prozent).") + if v < gmn or v > gmx: + raise HTTPException( + 400, + f"Prozentwert muss zwischen {gmn} und {gmx} liegen.", + ) + if pos and v <= 0: + raise HTTPException(400, "Prozentwert muss positiv sein (laut Konfiguration).") + else: + if pos and v <= 0: + raise HTTPException(400, "Der Wert muss positiv sein (laut Konfiguration).") + if mn is not None and v < mn: + raise HTTPException(400, f"Der Wert muss mindestens {mn} sein.") + if mx is not None and v > mx: + raise HTTPException(400, f"Der Wert darf höchstens {mx} sein.") + return v, None + + # text / enum + s = (value_text or "").strip() if value_text is not None else "" + if vdt == "text" and not s and not rules.get("not_empty"): + return None, "" + if rules.get("not_empty") and not s: + raise HTTPException(400, "Der Text darf nicht leer sein.") + ml = rules.get("max_length") + if ml is not None: + try: + ml_int = int(ml) + except (TypeError, ValueError): + ml_int = None + if ml_int is not None and len(s) > ml_int: + raise HTTPException(400, f"Text zu lang (max. {ml_int} Zeichen).") + if vdt == "enum": + allowed = rules.get("allowed_values") or [] + if not isinstance(allowed, list): + allowed = [] + allowed_str = [str(x).strip() for x in allowed if str(x).strip()] + if not allowed_str: + raise HTTPException(500, "ENUM-Typ ohne erlaubte Werte (Admin-Konfiguration).") + if s not in allowed_str: + raise HTTPException( + 400, + f"Ungültiger Wert. Erlaubt: {', '.join(allowed_str)}", + ) + return None, s diff --git a/backend/routers/activity.py b/backend/routers/activity.py index c3b57a0..b2e2009 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -9,7 +9,7 @@ import uuid import logging from typing import Optional -from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends +from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage @@ -32,24 +32,45 @@ logger = logging.getLogger(__name__) @router.get("") -def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): - """Get activity entries for current profile.""" +def list_activity( + limit: int = Query(200, ge=1, le=50_000), + days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*).""" pid = get_pid(x_profile_id) with get_db() as conn: cur = get_cursor(conn) - # Issue #31: Apply global quality filter + # Issue #31: Apply global quality filter (profile from DB = saved level) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) profile = r2d(cur.fetchone()) quality_filter = get_quality_filter_sql(profile) - cur.execute(f""" - SELECT * FROM activity_log - WHERE profile_id=%s - {quality_filter} - ORDER BY date DESC, start_time DESC - LIMIT %s - """, (pid, limit)) + if days is not None: + cur.execute( + f""" + SELECT * FROM activity_log + WHERE profile_id=%s + {quality_filter} + AND date >= (CURRENT_DATE - %s::integer) + ORDER BY date DESC, start_time DESC + LIMIT %s + """, + (pid, days, limit), + ) + else: + cur.execute( + f""" + SELECT * FROM activity_log + WHERE profile_id=%s + {quality_filter} + ORDER BY date DESC, start_time DESC + LIMIT %s + """, + (pid, limit), + ) return [r2d(r) for r in cur.fetchall()] diff --git a/backend/routers/admin.py b/backend/routers/admin.py index e7d5a62..871b2f2 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -7,12 +7,21 @@ import os import smtplib from email.mime.text import MIMEText from datetime import datetime +from typing import Any from fastapi import APIRouter, HTTPException, Depends from db import get_db, get_cursor, r2d from auth import require_admin, hash_pin from models import AdminProfileUpdate +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from dashboard_widget_entitlements import widgets_catalog_admin_payload +from system_dashboard_product_default import ( + delete_product_default_override, + get_product_default_base_dict, + get_stored_product_default_validated, + upsert_product_default_base, +) router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -155,3 +164,52 @@ def admin_test_email(data: dict, session: dict=Depends(require_admin)): return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"} except Exception as e: raise HTTPException(500, f"Fehler beim Senden: {str(e)}") + + +@router.get("/widgets/catalog-full") +def admin_widgets_catalog_full(session: dict = Depends(require_admin)): + """Dashboard-Widget-Katalog ohne Feature-Filter (Konfiguration des Produkt-Standards).""" + _ = session + return widgets_catalog_admin_payload() + + +@router.get("/dashboard-product-default") +def admin_get_dashboard_product_default(session: dict = Depends(require_admin)): + """Aktueller Produkt-Dashboard-Standard (DB oder Code).""" + _ = session + with get_db() as conn: + layout = get_product_default_base_dict(conn) + from_database = get_stored_product_default_validated(conn) is not None + code_ref = product_default_layout_dict() + return { + "from_database": from_database, + "layout": layout, + "code_reference": code_ref, + } + + +@router.put("/dashboard-product-default") +def admin_put_dashboard_product_default( + body: dict[str, Any], + session: dict = Depends(require_admin), +): + """System-Standard persistieren (JSON wie Nutzer-Layout v1).""" + _ = session + try: + payload = DashboardLayoutPayload.model_validate(body) + except Exception as e: + raise HTTPException(422, str(e)) from e + stored = payload.to_stored_dict() + with get_db() as conn: + upsert_product_default_base(conn, stored) + return {"ok": True, "layout": stored, "from_database": True} + + +@router.delete("/dashboard-product-default") +def admin_delete_dashboard_product_default(session: dict = Depends(require_admin)): + """DB-Override entfernen; App fällt auf Code-Standard zurück.""" + _ = session + with get_db() as conn: + delete_product_default_override(conn) + layout = get_product_default_base_dict(conn) + return {"ok": True, "layout": layout, "from_database": False} diff --git a/backend/routers/admin_reference_value_types.py b/backend/routers/admin_reference_value_types.py new file mode 100644 index 0000000..b99f04c --- /dev/null +++ b/backend/routers/admin_reference_value_types.py @@ -0,0 +1,282 @@ +""" +Admin: Referenzwert-Typen (Katalog für persönliche Referenzwerte). + +Nur Admins; Nutzer sehen nur aktive Typen über /api/reference-value-types. +""" +import re +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from psycopg2 import errors as pg_errors +from psycopg2.extras import Json + +from auth import require_admin +from db import get_db, get_cursor, r2d +from reference_value_validation import VALUE_DATA_TYPES + +router = APIRouter(prefix="/api/admin/reference-value-types", tags=["admin", "reference-value-types"]) + +KEY_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,62}$") + + +def _serialize_type(row: dict[str, Any]) -> dict[str, Any]: + if not row: + return row + out = dict(row) + ca = out.get("created_at") + if ca is not None and hasattr(ca, "isoformat"): + out["created_at"] = ca.isoformat() + return out + + +def _normalize_value_data_type(v: str) -> str: + s = (v or "decimal").strip().lower() + if s not in VALUE_DATA_TYPES: + raise HTTPException( + 400, + f"Ungültiger Datentyp: {s}. Erlaubt: {', '.join(sorted(VALUE_DATA_TYPES))}", + ) + return s + + +class ReferenceValueTypeAdminCreate(BaseModel): + key: str = Field(..., min_length=1, max_length=64) + label: str = Field(..., min_length=1, max_length=200) + description: Optional[str] = None + category: Optional[str] = None + default_unit: Optional[str] = Field(None, max_length=32) + value_data_type: str = "decimal" + validation_rules: Optional[dict] = None + active: bool = True + metadata: Optional[dict] = None + + +class ReferenceValueTypeAdminUpdate(BaseModel): + label: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + category: Optional[str] = None + default_unit: Optional[str] = Field(None, max_length=32) + value_data_type: Optional[str] = None + validation_rules: Optional[dict] = None + active: Optional[bool] = None + metadata: Optional[dict] = None + + +class ReferenceValueTypesReorderBody(BaseModel): + ordered_ids: list[int] = Field(..., min_length=1) + + +def _normalize_key(key: str) -> str: + k = key.strip().lower() + if not KEY_PATTERN.match(k): + raise HTTPException( + 400, + "Ungültiger Schlüssel: nur Kleinbuchstaben, Ziffern, Unterstriche; muss mit Buchstabe beginnen.", + ) + return k + + +def _unit_or_none(u: Optional[str]) -> Optional[str]: + if u is None: + return None + s = u.strip() + return s if s else None + + +def _cat_or_none(c: Optional[str]) -> Optional[str]: + if c is None: + return None + s = c.strip() + return s if s else None + + +@router.get("") +def admin_list_reference_value_types(session: dict = Depends(require_admin)): + """Alle Typen inkl. inaktiver (Admin-Übersicht).""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT + id, key, label, description, category, default_unit, value_data_type, + validation_rules, sort_order, active, metadata, created_at + FROM reference_value_types + ORDER BY sort_order ASC, id ASC + """ + ) + return [_serialize_type(r2d(r)) for r in cur.fetchall()] + + +@router.post("/reorder") +def admin_reorder_reference_value_types( + body: ReferenceValueTypesReorderBody, + session: dict = Depends(require_admin), +): + """Globale Reihenfolge setzen (sort_order = 10, 20, …). Liste muss alle Typ-IDs genau einmal enthalten.""" + ids = body.ordered_ids + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM reference_value_types") + all_ids = sorted([r["id"] for r in cur.fetchall()]) + if len(ids) != len(all_ids): + raise HTTPException( + 400, + f"Erwartet {len(all_ids)} Einträge, erhalten {len(ids)}.", + ) + if sorted(ids) != all_ids: + raise HTTPException(400, "Die ID-Liste muss alle Kennwert-Typen exakt einmal enthalten (keine Duplikate).") + if len(set(ids)) != len(ids): + raise HTTPException(400, "Doppelte IDs sind nicht erlaubt.") + + with get_db() as conn: + cur = get_cursor(conn) + for idx, tid in enumerate(ids): + cur.execute( + "UPDATE reference_value_types SET sort_order = %s WHERE id = %s", + ((idx + 1) * 10, tid), + ) + return {"ok": True} + + +@router.get("/{type_id}") +def admin_get_reference_value_type(type_id: int, session: dict = Depends(require_admin)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT + id, key, label, description, category, default_unit, value_data_type, + validation_rules, sort_order, active, metadata, created_at + FROM reference_value_types WHERE id = %s + """, + (type_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Typ nicht gefunden") + return _serialize_type(r2d(row)) + + +@router.post("") +def admin_create_reference_value_type( + body: ReferenceValueTypeAdminCreate, + session: dict = Depends(require_admin), +): + key = _normalize_key(body.key) + vdt = _normalize_value_data_type(body.value_data_type) + if not _unit_or_none(body.default_unit): + raise HTTPException(400, "Standard-Einheit ist erforderlich (wird bei der Erfassung fix verwendet).") + meta = body.metadata if body.metadata is not None else {} + rules = body.validation_rules if body.validation_rules is not None else {} + if vdt == "enum": + av = rules.get("allowed_values") if isinstance(rules, dict) else [] + if not isinstance(av, list) or not [x for x in av if str(x).strip()]: + raise HTTPException( + 400, + "Datentyp ENUM erfordert unter Plausibilisierung eine nicht-leere Liste „Erlaubte Werte“.", + ) + du = _unit_or_none(body.default_unit) + + with get_db() as conn: + cur = get_cursor(conn) + try: + cur.execute("SELECT COALESCE(MAX(sort_order), 0) AS m FROM reference_value_types") + next_sort = int(cur.fetchone()["m"]) + 10 + cur.execute( + """ + INSERT INTO reference_value_types + (key, label, description, category, default_unit, value_data_type, + validation_rules, sort_order, active, metadata) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING + id, key, label, description, category, default_unit, value_data_type, + validation_rules, sort_order, active, metadata, created_at + """, + ( + key, + body.label.strip(), + body.description.strip() if body.description else None, + _cat_or_none(body.category), + du, + vdt, + Json(rules), + next_sort, + body.active, + Json(meta), + ), + ) + return _serialize_type(r2d(cur.fetchone())) + except pg_errors.UniqueViolation: + raise HTTPException(409, "Ein Typ mit diesem Schlüssel existiert bereits.") + + +@router.put("/{type_id}") +def admin_update_reference_value_type( + type_id: int, + body: ReferenceValueTypeAdminUpdate, + session: dict = Depends(require_admin), +): + patch = body.model_dump(exclude_unset=True) + if not patch: + raise HTTPException(400, "Keine Felder zum Aktualisieren") + + if "value_data_type" in patch and patch["value_data_type"] is not None: + patch["value_data_type"] = _normalize_value_data_type(patch["value_data_type"]) + if "default_unit" in patch: + patch["default_unit"] = _unit_or_none(patch.get("default_unit")) + if patch["default_unit"] is None: + raise HTTPException(400, "Standard-Einheit darf nicht leer werden.") + if "description" in patch and patch["description"] is not None: + patch["description"] = patch["description"].strip() or None + if "category" in patch: + patch["category"] = _cat_or_none(patch.get("category")) + if "metadata" in patch: + patch["metadata"] = Json(patch["metadata"] if patch["metadata"] is not None else {}) + if "validation_rules" in patch: + patch["validation_rules"] = Json(patch["validation_rules"] if patch["validation_rules"] is not None else {}) + + cols = [] + vals = [] + for k, v in patch.items(): + cols.append(f"{k} = %s") + vals.append(v) + vals.append(type_id) + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + f""" + UPDATE reference_value_types SET {", ".join(cols)} + WHERE id = %s + RETURNING + id, key, label, description, category, default_unit, value_data_type, + validation_rules, sort_order, active, metadata, created_at + """, + tuple(vals), + ) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Typ nicht gefunden") + return _serialize_type(r2d(row)) + + +@router.delete("/{type_id}") +def admin_delete_reference_value_type(type_id: int, session: dict = Depends(require_admin)): + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT COUNT(*) AS c FROM profile_reference_values WHERE reference_value_type_id = %s", + (type_id,), + ) + n = cur.fetchone()["c"] + if n > 0: + raise HTTPException( + 409, + f"Es gibt noch {n} gespeicherte Referenzwert(e) zu diesem Typ. " + "Bitte zuerst löschen oder den Typ deaktivieren (active = aus).", + ) + cur.execute("DELETE FROM reference_value_types WHERE id = %s RETURNING id", (type_id,)) + if not cur.fetchone(): + raise HTTPException(404, "Typ nicht gefunden") + return {"ok": True} diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py new file mode 100644 index 0000000..3f0aff6 --- /dev/null +++ b/backend/routers/app_dashboard.py @@ -0,0 +1,115 @@ +""" +Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard). + +/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id). +""" +from typing import Any, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException +from psycopg2.extras import Json + +from auth import require_auth +from dashboard_layout_schema import ( + DashboardLayoutPayload, + coalesce_effective_layout, + lab_default_layout_dict, +) +from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload +from db import get_cursor, get_db +from routers.profiles import get_pid +from system_dashboard_product_default import get_product_default_base_dict + +router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"]) + + +@router.get("/widgets/catalog") +def get_widgets_catalog( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + """Katalog inkl. allowed pro Widget (Feature / Subscription, effektiver Tier).""" + _ = session + pid = get_pid(x_profile_id) + with get_db() as conn: + return widgets_catalog_payload(pid, conn) + + +@router.get("/dashboard-layout") +def get_dashboard_layout( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "SELECT dashboard_layout FROM profiles WHERE id = %s", + (pid,), + ) + row = cur.fetchone() + raw = row["dashboard_layout"] if row else None + custom, effective = coalesce_effective_layout(raw) + with get_db() as conn: + base_product = get_product_default_base_dict(conn) + if not custom: + effective = base_product + effective = apply_entitlements_to_layout_dict(effective, pid, conn) + product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn) + lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn) + return { + "custom": custom, + "layout": effective, + "product_default_layout": product_adj, + "lab_default_layout": lab_adj, + } + + +@router.put("/dashboard-layout") +def put_dashboard_layout( + body: dict[str, Any], + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + try: + payload = DashboardLayoutPayload.model_validate(body) + except Exception as e: + raise HTTPException(422, str(e)) from e + with get_db() as conn: + adjusted = apply_entitlements_to_layout_dict(payload.to_stored_dict(), pid, conn) + try: + payload = DashboardLayoutPayload.model_validate(adjusted) + except Exception as e: + raise HTTPException(422, str(e)) from e + stored = payload.to_stored_dict() + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE profiles SET dashboard_layout = %s WHERE id = %s", + (Json(stored), pid), + ) + if cur.rowcount == 0: + raise HTTPException(404, "Profil nicht gefunden") + return {"ok": True, "layout": stored} + + +@router.post("/dashboard-layout/reset") +def reset_dashboard_layout( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +) -> dict[str, Any]: + _ = session + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "UPDATE profiles SET dashboard_layout = NULL WHERE id = %s", + (pid,), + ) + if cur.rowcount == 0: + raise HTTPException(404, "Profil nicht gefunden") + base = get_product_default_base_dict(conn) + cleared = apply_entitlements_to_layout_dict(base, pid, conn) + return {"ok": True, "layout": cleared} diff --git a/backend/routers/focus_areas.py b/backend/routers/focus_areas.py index d41d6b0..cfc70b4 100644 --- a/backend/routers/focus_areas.py +++ b/backend/routers/focus_areas.py @@ -3,10 +3,11 @@ Focus Areas Router Manages dynamic focus area definitions and user preferences """ from fastapi import APIRouter, HTTPException, Depends -from pydantic import BaseModel +from pydantic import BaseModel, Field from typing import Optional, List from db import get_db, get_cursor, r2d from auth import require_auth +from focus_area_usage_helpers import coerce_usage_type_keys router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"]) @@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel): """User's focus area weightings (dynamic)""" preferences: dict # {focus_area_id: weight_pct} + +class FocusAreaUsageTypesUpdate(BaseModel): + """Replace all usage-type assignments for one focus area (admin).""" + usage_type_keys: List[str] = Field(default_factory=list) + # ============================================================================ # Focus Area Definitions (Admin) # ============================================================================ @@ -58,14 +64,27 @@ def list_focus_area_definitions( query = """ SELECT id, key, name_de, name_en, icon, description, category, is_active, - created_at, updated_at + created_at, updated_at, + COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = focus_area_definitions.id + ), + '[]'::json + ) AS allowed_usage_type_keys FROM focus_area_definitions WHERE is_active = true OR %s ORDER BY category, name_de """ cur.execute(query, (include_inactive,)) - areas = [r2d(row) for row in cur.fetchall()] + areas = [] + for row in cur.fetchall(): + d = r2d(row) + d['allowed_usage_type_keys'] = coerce_usage_type_keys(d.get('allowed_usage_type_keys')) + areas.append(d) # Group by category grouped = {} @@ -75,6 +94,10 @@ def list_focus_area_definitions( grouped[cat] = [] grouped[cat].append(area) + if session.get('role') != 'admin': + for area in areas: + area.pop('allowed_usage_type_keys', None) + return { "areas": areas, "grouped": grouped, @@ -226,6 +249,92 @@ def delete_focus_area_definition( return {"message": "Focus Area gelöscht"} + +@router.get("/usage-types") +def list_focus_area_usage_types(session: dict = Depends(require_auth)): + """ + Liste aller systemdefinierten Nutzungstypen (Admin, Konfigurations-UI). + Keine freie Anlage neuer Typen über die API. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT id, key, label_de, sort_order + FROM focus_area_usage_types + ORDER BY sort_order, key + """) + rows = [r2d(r) for r in cur.fetchall()] + return {"usage_types": rows, "total": len(rows)} + + +@router.put("/definitions/{area_id}/usage-types") +def replace_focus_area_usage_types( + area_id: str, + data: FocusAreaUsageTypesUpdate, + session: dict = Depends(require_auth), +): + """ + Ersetzt die Nutzungstyp-Zuweisungen einer Focus Area (Admin). + Leere Liste entfernt alle Zuordnungen. Unbekannte Keys → 400. + """ + if session.get('role') != 'admin': + raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich") + + keys = list(dict.fromkeys(data.usage_type_keys)) # dedupe, preserve order + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute( + "SELECT id FROM focus_area_definitions WHERE id = %s", + (area_id,), + ) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="Focus Area nicht gefunden") + + if not keys: + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + return {"message": "Nutzungstyp-Zuweisungen entfernt", "usage_type_keys": []} + + placeholders = ','.join(['%s'] * len(keys)) + cur.execute( + f""" + SELECT id, key FROM focus_area_usage_types + WHERE key IN ({placeholders}) + """, + keys, + ) + found = {row['key']: row['id'] for row in cur.fetchall()} + missing = [k for k in keys if k not in found] + if missing: + raise HTTPException( + status_code=400, + detail=f"Unbekannte Nutzungstyp-Keys: {', '.join(missing)}", + ) + + cur.execute( + "DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s", + (area_id,), + ) + for k in keys: + ut_id = found[k] + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + VALUES (%s, %s) + ON CONFLICT (focus_area_id, usage_type_id) DO NOTHING + """, + (area_id, ut_id), + ) + + return {"message": "Nutzungstyp-Zuweisungen aktualisiert", "usage_type_keys": keys} + # ============================================================================ # User Focus Preferences # ============================================================================ diff --git a/backend/routers/reference_values.py b/backend/routers/reference_values.py new file mode 100644 index 0000000..ddbfaa5 --- /dev/null +++ b/backend/routers/reference_values.py @@ -0,0 +1,320 @@ +""" +Persönliche Referenzwerte (profilorientiert) + +Typkatalog system-seeded; Nutzer pflegt historische Einträge pro aktivem Profil. +Einheit immer aus dem Typ; Wert je value_data_type validiert. + +Reads (Liste, Summary, Katalog) → data_layer.reference_values (Layer 1). +""" +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 +from psycopg2.extras import Json + +from auth import require_auth +from data_layer.reference_values import ( + fetch_reference_type_by_key, + get_profile_reference_values_summary, + list_active_reference_value_types_data, + list_profile_reference_values_for_type, + normalize_reference_row, +) +from db import get_db, get_cursor, r2d +from reference_value_validation import ( + REF_VALUE_CONFIDENCE, + REF_VALUE_CONFIDENCE_ORDER, + REF_VALUE_METHODS, + REF_VALUE_SOURCES, + validate_meta_confidence, + validate_meta_method, + validate_meta_source, + validate_value_for_data_type, + resolve_unit_from_type, +) +from routers.profiles import get_pid + +router = APIRouter(prefix="/api", tags=["reference-values"]) + + +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 + source: str = Field(..., min_length=1) + method: str = Field(..., min_length=1) + confidence: str = Field(..., min_length=1) + notes: Optional[str] = None + extra: Optional[dict] = None + + +class ProfileReferenceValueUpdate(BaseModel): + effective_date: Optional[str] = None + value_numeric: Optional[float] = None + value_text: Optional[str] = None + source: Optional[str] = None + method: Optional[str] = None + confidence: 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 (dynamische UI inkl. Validierungsmetadaten).""" + return list_active_reference_value_types_data() + + +@router.get("/reference-value-meta/enums") +def list_reference_value_meta_enums(session: dict = Depends(require_auth)): + """Erlaubte Werte für Quelle, Methode, Vertrauensgrad (Erfassungsdialog).""" + return { + "sources": sorted(REF_VALUE_SOURCES), + "methods": sorted(REF_VALUE_METHODS), + "confidence_levels": [x for x in REF_VALUE_CONFIDENCE_ORDER if x in REF_VALUE_CONFIDENCE], + } + + +@router.get("/profile-reference-values/summary") +def profile_reference_values_summary( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + """ + Für das aktive Profil: je Referenztyp mit mindestens einem Eintrag der jüngste Wert + plus der unmittelbar vorherige (gleiche Sortierung wie Liste), für Tendenz-Anzeigen. + """ + pid = get_pid(x_profile_id) + return get_profile_reference_values_summary(pid) + + +@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) + rows = list_profile_reference_values_for_type(pid, type_key) + if rows is None: + raise HTTPException(404, "Referenztyp nicht gefunden") + return rows + + +@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") + + src = validate_meta_source(body.source) + meth = validate_meta_method(body.method) + conf = validate_meta_confidence(body.confidence) + + with get_db() as conn: + cur = get_cursor(conn) + t = fetch_reference_type_by_key(cur, body.reference_value_type_key.strip(), require_active=True) + if not t: + raise HTTPException(404, "Referenztyp nicht gefunden") + vdt = (t.get("value_data_type") or "decimal").strip().lower() + rules = t.get("validation_rules") or {} + vnum, vtxt = validate_value_for_data_type(vdt, rules, body.value_numeric, body.value_text) + unit = resolve_unit_from_type(t.get("default_unit")) + 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, + src, + conf, + meth, + 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 normalize_reference_row(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, rt.value_data_type, rt.validation_rules + 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) + + vdt = (cur_row.get("value_data_type") or "decimal").strip().lower() + rules = cur_row.get("validation_rules") or {} + + new_ed = patch.get("effective_date", cur_row["effective_date"]) + if hasattr(new_ed, "isoformat"): + new_ed = new_ed.isoformat() + + vn = patch["value_numeric"] if "value_numeric" in patch else cur_row.get("value_numeric") + vt_raw = patch["value_text"] if "value_text" in patch else cur_row.get("value_text") + if vn is not None and isinstance(vn, Decimal): + vn = float(vn) + + vnum, vtxt = validate_value_for_data_type(vdt, rules, vn, vt_raw) + + unit = resolve_unit_from_type(cur_row.get("default_unit")) + + if "source" in patch: + src = validate_meta_source(patch["source"]) + else: + src = validate_meta_source(cur_row.get("source")) + if "method" in patch: + meth = validate_meta_method(patch["method"]) + else: + meth = validate_meta_method(cur_row.get("method")) + if "confidence" in patch: + conf = validate_meta_confidence(patch["confidence"]) + else: + conf = validate_meta_confidence(cur_row.get("confidence")) + + updates: dict[str, Any] = { + "effective_date": new_ed, + "value_numeric": vnum, + "value_text": vtxt, + "unit": unit, + "source": src, + "method": meth, + "confidence": conf, + } + 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 normalize_reference_row(r2d(cur.fetchone())) + + +@router.delete("/profile-reference-values/{entry_id}") +def delete_profile_reference_value( + entry_id: int, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM profile_reference_values WHERE id = %s AND profile_id = %s RETURNING id", + (entry_id, pid), + ) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + return {"ok": True} diff --git a/backend/system_dashboard_product_default.py b/backend/system_dashboard_product_default.py new file mode 100644 index 0000000..5c423d8 --- /dev/null +++ b/backend/system_dashboard_product_default.py @@ -0,0 +1,77 @@ +""" +Persistenter System-Standard für die Produkt-Übersicht (Dashboard). + +Key in system_config: dashboard_product_default — gültiges DashboardLayoutPayload (JSON). +""" +from __future__ import annotations + +from typing import Any + +from psycopg2.extras import Json + +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from db import get_cursor + +SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT = "dashboard_product_default" + + +def get_stored_product_default_validated(conn) -> dict[str, Any] | None: + """Gültiges Layout aus DB oder None (fehlt/ungültig).""" + cur = get_cursor(conn) + cur.execute( + "SELECT value FROM system_config WHERE key = %s", + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT,), + ) + row = cur.fetchone() + if not row or row.get("value") is None: + return None + raw = row["value"] + if isinstance(raw, str): + import json + + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(raw, dict): + return None + try: + payload = DashboardLayoutPayload.model_validate( + {"version": raw.get("version", 1), "widgets": raw.get("widgets", [])} + ) + return payload.to_stored_dict() + except Exception: + return None + + +def get_product_default_base_dict(conn) -> dict[str, Any]: + """Basis-Layout (ohne Entitlements): DB-Override oder Code-Standard.""" + stored = get_stored_product_default_validated(conn) + if stored is not None: + return stored + return product_default_layout_dict() + + +def upsert_product_default_base(conn, layout: dict[str, Any]) -> dict[str, Any]: + payload = DashboardLayoutPayload.model_validate(layout) + stored = payload.to_stored_dict() + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO system_config (key, value, updated_at) + VALUES (%s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP + """, + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT, Json(stored)), + ) + return stored + + +def delete_product_default_override(conn) -> None: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM system_config WHERE key = %s", + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT,), + ) diff --git a/backend/tests/test_dashboard_layout_schema.py b/backend/tests/test_dashboard_layout_schema.py new file mode 100644 index 0000000..60bc7ac --- /dev/null +++ b/backend/tests/test_dashboard_layout_schema.py @@ -0,0 +1,58 @@ +import pytest + +from dashboard_layout_schema import ( + ALLOWED_WIDGET_IDS, + DashboardLayoutPayload, + coalesce_effective_layout, + default_layout_dict, +) +from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS + + +def test_default_has_all_allowed_ids(): + d = default_layout_dict() + got = {w["id"] for w in d["widgets"]} + assert got == ALLOWED_WIDGET_IDS + assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS + + +def test_payload_rejects_duplicate_ids(): + with pytest.raises(Exception): + DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "welcome", "enabled": False}, + ], + } + ) + + +def test_payload_requires_one_enabled(): + with pytest.raises(Exception): + DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [{"id": "dashboard_greeting", "enabled": False}], + } + ) + + +def test_coalesce_none(): + custom, eff = coalesce_effective_layout(None) + assert custom is False + assert eff == default_layout_dict() + + +def test_coalesce_valid_raw(): + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "kpi_board", "enabled": True}, + ], + } + custom, eff = coalesce_effective_layout(raw) + assert custom is True + assert eff == raw diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py new file mode 100644 index 0000000..9f30f19 --- /dev/null +++ b/backend/tests/test_dashboard_widget_config.py @@ -0,0 +1,133 @@ +import pytest + +from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, default_layout_dict +from dashboard_widget_config import validate_widget_entry_config + + +def test_body_chart_days_bounds(): + assert validate_widget_entry_config("body_overview", {"chart_days": 7}) == {"chart_days": 7} + assert validate_widget_entry_config("body_overview", {"chart_days": 90}) == {"chart_days": 90} + assert validate_widget_entry_config("body_overview", {"chart_days": 42.0}) == {"chart_days": 42} + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 6}) + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 91}) + + +def test_welcome_config_rejected_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("welcome", {"x": 1}) + + +def test_body_unknown_key(): + with pytest.raises(ValueError): + validate_widget_entry_config("body_overview", {"chart_days": 30, "extra": 1}) + + +def test_activity_chart_days(): + assert validate_widget_entry_config("activity_overview", {"chart_days": 14}) == {"chart_days": 14} + with pytest.raises(ValueError): + validate_widget_entry_config("activity_overview", {"chart_days": 5}) + + +def test_kpi_board_tiles(): + assert validate_widget_entry_config("kpi_board", {}) == {} + assert validate_widget_entry_config("kpi_board", {"tiles": []}) == {"tiles": []} + assert validate_widget_entry_config( + "kpi_board", + {"tiles": [{"id": "body_fat"}, {"id": "avg_kcal"}, {"id": "ref:hr_max"}]}, + ) == {"tiles": [{"id": "body_fat"}, {"id": "avg_kcal"}, {"id": "ref:hr_max"}]} + with pytest.raises(ValueError): + validate_widget_entry_config("kpi_board", {"tiles": [{"id": "unknown"}]}) + with pytest.raises(ValueError): + validate_widget_entry_config("kpi_board", {"tiles": [{"id": "body_fat"}, {"id": "body_fat"}]}) + with pytest.raises(ValueError): + validate_widget_entry_config("kpi_board", {"extra": 1}) + + +def test_quick_capture_visibility(): + assert validate_widget_entry_config("quick_capture", {}) == {} + assert validate_widget_entry_config("quick_capture", {"show_weight": False}) == {"show_weight": False} + full = { + "show_weight": True, + "show_resting_hr": False, + "show_hrv": True, + "show_vo2_max": False, + } + assert validate_widget_entry_config("quick_capture", full) == full + with pytest.raises(ValueError): + validate_widget_entry_config("quick_capture", {"show_weight": "yes"}) + with pytest.raises(ValueError): + validate_widget_entry_config( + "quick_capture", + { + "show_weight": False, + "show_resting_hr": False, + "show_hrv": False, + "show_vo2_max": False, + }, + ) + with pytest.raises(ValueError): + validate_widget_entry_config("quick_capture", {"extra": 1}) + + +def test_nutrition_detail_charts_days(): + assert validate_widget_entry_config("nutrition_detail_charts", {}) == {} + assert validate_widget_entry_config("nutrition_detail_charts", {"chart_days": 60}) == {"chart_days": 60} + with pytest.raises(ValueError): + validate_widget_entry_config("nutrition_detail_charts", {"chart_days": 3}) + + +def test_recovery_charts_panel_days(): + assert validate_widget_entry_config("recovery_charts_panel", {}) == {} + assert validate_widget_entry_config("recovery_charts_panel", {"chart_days": 28}) == {"chart_days": 28} + with pytest.raises(ValueError): + validate_widget_entry_config("recovery_charts_panel", {"chart_days": 99}) + + +def test_trend_kcal_weight_chart_days(): + assert validate_widget_entry_config("trend_kcal_weight", {}) == {} + assert validate_widget_entry_config("trend_kcal_weight", {"chart_days": 30}) == {"chart_days": 30} + with pytest.raises(ValueError): + validate_widget_entry_config("trend_kcal_weight", {"chart_days": 6}) + + +def test_kpi_board_legacy_chart_days_dropped(): + """Nur chart_days (Alt-Layouts) → automatische Kachelwahl, kein Ø-Kal-Fenster mehr.""" + assert validate_widget_entry_config("kpi_board", {"chart_days": 14}) == {} + assert validate_widget_entry_config("kpi_board", {"chart_days": 5}) == {} + + +def test_welcome_still_rejects_config(): + with pytest.raises(ValueError): + validate_widget_entry_config("welcome", {"chart_days": 30}) + + +def test_layout_payload_with_chart_days_roundtrip(): + p = DashboardLayoutPayload.model_validate( + { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + { + "id": "body_overview", + "enabled": True, + "config": {"chart_days": 42}, + }, + ], + } + ) + d = p.to_stored_dict() + assert d["widgets"][1]["config"]["chart_days"] == 42 + + +def test_coalesce_rejects_invalid_widget_config(): + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True, "config": {"evil": True}}, + ], + } + custom, eff = coalesce_effective_layout(raw) + assert custom is False + assert eff == default_layout_dict() diff --git a/backend/tests/test_dashboard_widget_entitlements.py b/backend/tests/test_dashboard_widget_entitlements.py new file mode 100644 index 0000000..cd1efef --- /dev/null +++ b/backend/tests/test_dashboard_widget_entitlements.py @@ -0,0 +1,57 @@ +from dashboard_layout_schema import DashboardLayoutPayload +from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widget_id_allowed + + +def test_apply_entitlements_disables_widget_without_access(monkeypatch): + monkeypatch.setattr( + "dashboard_widget_entitlements.widget_id_allowed", + lambda wid, pid, conn: wid != "nutrition_detail_charts", + ) + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": True}, + {"id": "nutrition_detail_charts", "enabled": True}, + ], + } + out = apply_entitlements_to_layout_dict(raw, "p", None) + assert {w["id"]: w["enabled"] for w in out["widgets"]} == { + "welcome": True, + "nutrition_detail_charts": False, + } + + +def test_apply_entitlements_leaves_welcome_on_when_all_blocked(monkeypatch): + monkeypatch.setattr( + "dashboard_widget_entitlements.widget_id_allowed", + lambda wid, pid, conn: False, + ) + raw = { + "version": 1, + "widgets": [ + {"id": "welcome", "enabled": False}, + {"id": "nutrition_detail_charts", "enabled": False}, + ], + } + out = apply_entitlements_to_layout_dict(raw, "p", None) + assert any(w["id"] == "welcome" and w["enabled"] for w in out["widgets"]) + + +def test_widget_id_allowed_false_for_unknown_id(): + assert widget_id_allowed("not-a-widget", "p", None) is False + + +def test_full_default_layout_still_validates_after_entitlements(monkeypatch): + monkeypatch.setattr( + "dashboard_widget_entitlements.widget_id_allowed", + lambda wid, pid, conn: wid != "ai_pipeline_insight", + ) + from dashboard_layout_schema import default_layout_dict + + d = default_layout_dict() + d["widgets"] = [{**x, "enabled": x["id"] == "ai_pipeline_insight"} for x in d["widgets"]] + adj = apply_entitlements_to_layout_dict(d, "p", None) + p2 = DashboardLayoutPayload.model_validate(adj) + ai = next(w for w in p2.widgets if w.id == "ai_pipeline_insight") + assert ai.enabled is False + assert any(w.enabled for w in p2.widgets) diff --git a/backend/tests/test_focus_area_usage_types.py b/backend/tests/test_focus_area_usage_types.py new file mode 100644 index 0000000..3340770 --- /dev/null +++ b/backend/tests/test_focus_area_usage_types.py @@ -0,0 +1,127 @@ +""" +Tests: Focus Area Nutzungstypen (Migration 036, Router-Helfer). + +Ohne MITAI_INTEGRATION_DB=1 werden nur SQL-Datei und reine Python-Helfer geprüft. +Mit gesetztem Flag optional Verifikation gegen eine PostgreSQL-Instanz (Migration 036 angewendet). +""" +from __future__ import annotations + +import os +import sys +import uuid +from pathlib import Path + +import pytest + +BACKEND_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BACKEND_ROOT)) + + +def test_migration_036_defines_schema_and_seeds_keys(): + p = BACKEND_ROOT / "migrations" / "036_focus_area_usage_types.sql" + assert p.is_file(), f"expected {p}" + text = p.read_text(encoding="utf-8") + assert "CREATE TABLE IF NOT EXISTS focus_area_usage_types" in text + assert "CREATE TABLE IF NOT EXISTS focus_area_definition_usage_types" in text + for key in ( + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ): + assert key in text + assert "ON CONFLICT (key) DO NOTHING" in text + # Explizit: kein automatisches Befüllen der M:N-Tabelle + assert "INSERT INTO focus_area_definition_usage_types" not in text + + +def test_coerce_usage_type_keys_normalizes_values(): + from focus_area_usage_helpers import coerce_usage_type_keys + + assert coerce_usage_type_keys(None) == [] + assert coerce_usage_type_keys([]) == [] + assert coerce_usage_type_keys(["goal_priority", "expected_training_effect"]) == [ + "goal_priority", + "expected_training_effect", + ] + assert coerce_usage_type_keys('["concrete_training_contribution"]') == [ + "concrete_training_contribution" + ] + + +@pytest.mark.skipif( + os.getenv("MITAI_INTEGRATION_DB") != "1", + reason="Set MITAI_INTEGRATION_DB=1 plus DB_* env to run DB checks (nur Dev/CI!)", +) +def test_integration_focus_area_usage_types_seeded_and_junction_writable(): + """Nur gegen Dev-DB ausführen. Nutzt temporäre focus_area_definitions-Zeile, keine bestehenden Daten.""" + from db import get_db, get_cursor + from focus_area_usage_helpers import coerce_usage_type_keys + + tmp_id = str(uuid.uuid4()) + tmp_key = f"tmp_usage_{tmp_id[:8]}" + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """ + SELECT key FROM focus_area_usage_types + ORDER BY sort_order, key + """ + ) + keys = [row["key"] for row in cur.fetchall()] + assert keys == [ + "goal_priority", + "expected_training_effect", + "concrete_training_contribution", + ] + + cur.execute( + """ + INSERT INTO focus_area_definitions + (id, key, name_de, category, is_active) + VALUES (%s, %s, 'tmp_test_usage', 'custom', false) + """, + (tmp_id, tmp_key), + ) + + cur.execute( + """ + SELECT COALESCE( + ( + SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key) + FROM focus_area_definition_usage_types fadut + JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id + WHERE fadut.focus_area_id = %s + ), + '[]'::json + ) AS allowed_usage_type_keys + """, + (tmp_id,), + ) + assert coerce_usage_type_keys(cur.fetchone()["allowed_usage_type_keys"]) == [] + + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "goal_priority"), + ) + cur.execute( + """ + INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id) + SELECT %s, u.id FROM focus_area_usage_types u WHERE u.key = %s + """, + (tmp_id, "expected_training_effect"), + ) + cur.execute( + """ + SELECT COUNT(*) AS n + FROM focus_area_definition_usage_types + WHERE focus_area_id = %s + """, + (tmp_id,), + ) + assert cur.fetchone()["n"] == 2 + + cur.execute("DELETE FROM focus_area_definitions WHERE id = %s", (tmp_id,)) diff --git a/backend/tests/test_reference_values_data_layer.py b/backend/tests/test_reference_values_data_layer.py new file mode 100644 index 0000000..aca4dd6 --- /dev/null +++ b/backend/tests/test_reference_values_data_layer.py @@ -0,0 +1,48 @@ +"""Unit tests for data_layer.reference_values (summary assembly, no DB).""" + +from data_layer.reference_values import build_summary_tiles_from_ranked_rows + + +def test_build_summary_tiles_single_type_two_rows(): + raw = [ + { + "type_key": "hr_max", + "type_label": "HF max", + "type_sort_order": 1, + "value_data_type": "decimal", + "rn": 1, + "id": 2, + "effective_date": "2026-04-01", + "value_numeric": 180.0, + "value_text": None, + "unit": "bpm", + }, + { + "type_key": "hr_max", + "type_label": "HF max", + "type_sort_order": 1, + "value_data_type": "decimal", + "rn": 2, + "id": 1, + "effective_date": "2026-03-01", + "value_numeric": 175.0, + "value_text": None, + "unit": "bpm", + }, + ] + tiles = build_summary_tiles_from_ranked_rows(raw) + assert len(tiles) == 1 + t = tiles[0] + assert t["type_key"] == "hr_max" + assert t["latest"]["value_numeric"] == 180.0 + assert t["previous"]["value_numeric"] == 175.0 + assert "sort_key" not in t + + +def test_build_summary_tiles_multi_type_order(): + raw = [ + {"type_key": "b", "type_label": "B", "type_sort_order": 2, "value_data_type": "decimal", "rn": 1, "id": 1}, + {"type_key": "a", "type_label": "A", "type_sort_order": 1, "value_data_type": "decimal", "rn": 1, "id": 2}, + ] + tiles = build_summary_tiles_from_ranked_rows(raw) + assert [x["type_key"] for x in tiles] == ["a", "b"] diff --git a/backend/tests/test_system_dashboard_product_default.py b/backend/tests/test_system_dashboard_product_default.py new file mode 100644 index 0000000..8bd9af6 --- /dev/null +++ b/backend/tests/test_system_dashboard_product_default.py @@ -0,0 +1,45 @@ +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from dashboard_widget_entitlements import widgets_catalog_admin_payload + + +def test_widgets_catalog_admin_all_allowed(): + p = widgets_catalog_admin_payload() + assert p["catalog_version"] == 1 + assert len(p["widgets"]) >= 1 + assert all(w["allowed"] is True for w in p["widgets"]) + + +def test_get_product_default_base_uses_code_when_no_row(monkeypatch): + from system_dashboard_product_default import get_product_default_base_dict + + class _Cur: + def execute(self, *a, **k): + pass + + def fetchone(self): + return None + + monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) + assert get_product_default_base_dict(object()) == product_default_layout_dict() + + +def test_get_product_default_base_uses_db_when_valid(monkeypatch): + from system_dashboard_product_default import get_product_default_base_dict + + from widget_catalog import ALLOWED_WIDGET_IDS + + small = { + "version": 1, + "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], + } + DashboardLayoutPayload.model_validate(small) + + class _Cur: + def execute(self, *a, **k): + pass + + def fetchone(self): + return {"value": small} + + monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) + assert get_product_default_base_dict(object()) == small diff --git a/backend/tests/test_training_profile_resolver.py b/backend/tests/test_training_profile_resolver.py new file mode 100644 index 0000000..de91294 --- /dev/null +++ b/backend/tests/test_training_profile_resolver.py @@ -0,0 +1,138 @@ +""" +Unit tests: Layer 1 training profile resolver scaffold. + +No database; pure template + algorithm + resolver behavior. +""" + +import pytest + +from data_layer.training_profile import ( + CalculationTemplate, + DimensionSpec, + FocusAreaMapping, + TrainingEvaluationResult, + resolve_for_base_profile, + resolve_training_evaluation, +) +from data_layer.training_profile.algorithms.registry import ( + get_algorithm, + list_algorithm_ids, + register_algorithm, +) +from data_layer.training_profile.models import AlgorithmRunResult +from data_layer.training_profile.profiles.registry import get_training_base_profile +from data_layer.training_profile.templates.registry import get_calculation_template + + +class TestAlgorithmRegistry: + def test_builtin_algorithms_registered(self): + ids = list_algorithm_ids() + assert "threshold_band" in ids + assert "linear_range" in ids + + def test_get_algorithm_runs_threshold(self): + fn = get_algorithm("threshold_band") + r = fn( + inputs={"avg_hr": 130.0}, + params={ + "value_key": "avg_hr", + "bands": [ + {"max": 120, "score": 0.2}, + {"max": 150, "score": 0.8}, + {"max": None, "score": 1.0}, + ], + }, + ) + assert r.normalized_score == 0.8 + + def test_duplicate_register_raises(self): + def dummy(*, inputs, params): + return AlgorithmRunResult(0.0, 0.0, []) + + with pytest.raises(ValueError, match="already registered"): + register_algorithm("threshold_band", dummy) + + +class TestResolver: + def test_example_template_resolves(self): + tpl = get_calculation_template("scaffold_example_aerobic_v1") + result = resolve_training_evaluation( + activity_inputs={ + "avg_hr": 135.0, + "duration_min": 45.0, + "distance_km": 10.0, + }, + template=tpl, + ) + assert isinstance(result, TrainingEvaluationResult) + assert result.template_id == "scaffold_example_aerobic_v1" + assert result.confidence == "high" + assert "aerobic_endurance" in result.focus_area_contributions + assert len(result.dimension_results) == 2 + for dr in result.dimension_results: + assert dr.missing_inputs == [] + + def test_missing_required_input_skips_dimension(self): + tpl = get_calculation_template("scaffold_example_aerobic_v1") + result = resolve_training_evaluation( + activity_inputs={"avg_hr": 135.0}, + template=tpl, + ) + assert result.confidence in ("medium", "low", "insufficient") + skipped = [d for d in result.dimension_results if d.evidence.get("skipped")] + assert len(skipped) >= 1 + + def test_base_profile_filters_dimensions(self): + profile = get_training_base_profile("scaffold_strength_base") + tpl = get_calculation_template(profile.default_template_id) + result = resolve_training_evaluation( + activity_inputs={"duration_min": 50.0}, + template=tpl, + base_profile=profile, + ) + assert len(result.dimension_results) == 1 + assert result.dimension_results[0].dimension_key == "effort" + + def test_resolve_for_base_profile_convenience(self): + result = resolve_for_base_profile( + activity_inputs={"duration_min": 40.0}, + base_profile_key="scaffold_strength_base", + include_trace=True, + ) + assert result.base_profile_key == "scaffold_strength_base" + assert result.trace is not None + assert "effort" in result.trace + + def test_to_serializable(self): + tpl = get_calculation_template("scaffold_example_strength_v1") + r = resolve_training_evaluation( + activity_inputs={"duration_min": 45.0}, + template=tpl, + ) + d = r.to_serializable() + assert d["template_id"] == tpl.id + assert "focus_area_contributions" in d + assert isinstance(d["dimension_results"], list) + + +class TestCustomTemplate: + def test_unknown_algorithm_raises(self): + bad = CalculationTemplate( + id="bad", + version="1", + label="bad", + dimensions=( + DimensionSpec( + key="x", + algorithm_id="does_not_exist", + inputs=("a",), + params={}, + maps_to=(FocusAreaMapping("strength", 1.0),), + ), + ), + ) + with pytest.raises(KeyError): + resolve_training_evaluation( + activity_inputs={"a": 1.0}, + template=bad, + ) diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py new file mode 100644 index 0000000..478ead4 --- /dev/null +++ b/backend/tests/test_widget_catalog.py @@ -0,0 +1,59 @@ +"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response).""" + +from dashboard_layout_schema import default_layout_dict +from dashboard_widget_entitlements import widgets_catalog_payload +from widget_catalog import ( + ALLOWED_WIDGET_IDS, + DEFAULT_LAB_WIDGET_IDS, + DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS, + WIDGET_CATALOG, +) + + +def test_catalog_ids_unique_and_match_allowed(): + ids = [e["id"] for e in WIDGET_CATALOG] + assert len(ids) == len(set(ids)) + assert frozenset(ids) == ALLOWED_WIDGET_IDS + + +def test_default_layout_follows_catalog_order(): + d = default_layout_dict() + assert d["version"] == 1 + got = [w["id"] for w in d["widgets"]] + assert got == [e["id"] for e in WIDGET_CATALOG] + enabled_ids = {w["id"] for w in d["widgets"] if w["enabled"]} + assert enabled_ids == DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS + assert any(w["enabled"] for w in d["widgets"]) + + +def test_lab_default_matches_lab_widget_ids(): + from dashboard_layout_schema import lab_default_layout_dict + + d = lab_default_layout_dict() + assert {w["id"] for w in d["widgets"] if w["enabled"]} == DEFAULT_LAB_WIDGET_IDS + + +def test_catalog_payload_shape(monkeypatch): + monkeypatch.setattr( + "dashboard_widget_entitlements._check_feature_access", + lambda *args, **kwargs: {"allowed": True}, + ) + r = widgets_catalog_payload("test-profile", None) + assert r["catalog_version"] == 1 + assert len(r["widgets"]) == len(WIDGET_CATALOG) + assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS + for w in r["widgets"]: + assert set(w.keys()) == {"id", "title", "description", "allowed"} + assert w["allowed"] is True + + +def test_catalog_marks_disallowed_when_feature_blocks(monkeypatch): + def _check(_pid, feature_id, conn=None): + return {"allowed": feature_id != "nutrition_entries"} + + monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", _check) + r = widgets_catalog_payload("p", None) + by_id = {w["id"]: w for w in r["widgets"]} + assert by_id["welcome"]["allowed"] is True + assert by_id["nutrition_detail_charts"]["allowed"] is False + assert by_id["body_overview"]["allowed"] is True diff --git a/backend/version.py b/backend/version.py index 739a463..41d1c3c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,32 +9,36 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9n" BUILD_DATE = "2026-04-05" -DB_SCHEMA_VERSION = "20260403" # Migration 034 +DB_SCHEMA_VERSION = "20260406d" # Migration 040 MODULE_VERSIONS = { "auth": "1.2.0", "profiles": "1.1.0", + "reference_values": "1.3.0", + "admin_reference_value_types": "1.0.0", "weight": "1.0.3", "circumference": "1.0.1", "caliper": "1.0.1", - "activity": "1.1.0", + "activity": "1.2.0", # GET /activity: optional days= window + limit "nutrition": "1.0.2", "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", - "admin": "1.2.0", + "admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full "stats": "1.0.1", "exportdata": "1.1.0", "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.6.0", # Phase 4: End Node Template Engine + "app_dashboard": "1.10.0", # Produkt-Standard aus system_config; Response-Form unverändert } CHANGELOG = [ { "version": "0.9n", - "date": "2026-04-05", + "date": "2026-04-06", "changes": [ + "Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)", "Phase 4: End Node Template Engine", "workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)", "workflow_executor.py: execute_end_node() with Jinja2 template rendering", diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py new file mode 100644 index 0000000..f89a908 --- /dev/null +++ b/backend/widget_catalog.py @@ -0,0 +1,153 @@ +""" +Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard). + +Single Source für: erlaubte IDs, Standard-Reihenfolge, Anzeige-Metadaten für API/GUI. +Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerPilotLabWidgets). +""" +from __future__ import annotations + +from typing import Any, NotRequired, TypedDict + + +class WidgetCatalogEntry(TypedDict): + """requires_feature: optional features.id; fehlt oder leer → Widget immer sichtbar (nur Auth).""" + + id: str + title: str + description: str + requires_feature: NotRequired[str] + + +# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus). +WIDGET_CATALOG: list[WidgetCatalogEntry] = [ + { + "id": "welcome", + "title": "Willkommen", + "description": "Begrüßung und Kurzkontext", + }, + { + "id": "quick_capture", + "title": "Schnelleingabe", + "description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus); Feature weight_entries", + "requires_feature": "weight_entries", + }, + { + "id": "kpi_board", + "title": "KPI-Kacheln", + "description": "Referenzwerte, KF%, Ø-Kalorien — optional Kacheln & Reihenfolge (config.tiles, max. 9)", + }, + { + "id": "body_overview", + "title": "Körper (Chart)", + "description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries", + "requires_feature": "weight_entries", + }, + { + "id": "activity_overview", + "title": "Aktivität", + "description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90; Feature activity_entries", + "requires_feature": "activity_entries", + }, + { + "id": "dashboard_greeting", + "title": "Begrüßung (Produkt)", + "description": "Hallo, Datum & letztes Gewicht-Update", + }, + { + "id": "quick_weight_today", + "title": "Gewicht heute", + "description": "Tagesgewicht erfassen (wie Produkt-Dashboard); Feature weight_entries", + "requires_feature": "weight_entries", + }, + { + "id": "body_stat_strip", + "title": "Kennzahlen-Kacheln", + "description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe; u. a. nutrition_entries (Ø-kcal)", + "requires_feature": "nutrition_entries", + }, + { + "id": "status_pills", + "title": "Indikatoren (Pills)", + "description": "WHR, WHtR, Protein, KF; Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, + { + "id": "profile_goals_progress", + "title": "Profil-Ziele", + "description": "Fortschritt Gewicht/Körperfett aus Profilfeldern", + }, + { + "id": "trend_kcal_weight", + "title": "Trend Kalorien + Gewicht", + "description": "Linienchart (optional config chart_days 7–90, Default 30); Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, + { + "id": "nutrition_activity_summary", + "title": "Ernährung & Aktivität Kurz", + "description": "Ø 7T Kacheln; Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, + { + "id": "nutrition_detail_charts", + "title": "Ernährung — Detaillierte Charts", + "description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries", + "requires_feature": "nutrition_entries", + }, + { + "id": "recovery_charts_panel", + "title": "Erholung — Charts R1–R5", + "description": "RecoveryCharts wie Verlauf (optional chart_days 7–90, Default 28)", + }, + { + "id": "progress_photos", + "title": "Fortschrittsfotos", + "description": "Galerie der hochgeladenen Fotos; Feature photos", + "requires_feature": "photos", + }, + { + "id": "recovery_sleep_rest", + "title": "Erholung", + "description": "Schlaf-Widget & Ruhetage", + }, + { + "id": "goals_focus_teaser", + "title": "Ziele Teaser", + "description": "Kurzlink zur Ziele-Seite", + }, + { + "id": "ai_pipeline_insight", + "title": "KI Pipeline & letzte Analyse", + "description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline", + "requires_feature": "ai_pipeline", + }, +] + +DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset( + { + "welcome", + "quick_capture", + "kpi_board", + "body_overview", + "activity_overview", + } +) + +# Produkt-Übersicht (/): Default wenn Nutzer kein dashboard_layout in der DB hat (Physisch: nur Profil-JSON). +DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS: frozenset[str] = frozenset( + { + "dashboard_greeting", + "quick_weight_today", + "body_stat_strip", + "status_pills", + "trend_kcal_weight", + "nutrition_activity_summary", + "activity_overview", + "recovery_sleep_rest", + "goals_focus_teaser", + "profile_goals_progress", + "ai_pipeline_insight", + } +) + +ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 332cad7..154a50a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,6 +22,11 @@ import NutritionPage from './pages/NutritionPage' import ActivityPage from './pages/ActivityPage' import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' +import SettingsShell from './layouts/SettingsShell' +import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage' +import PilotVizPage from './pages/PilotVizPage' +import DashboardLabPage from './pages/DashboardLabPage' +import DashboardConfigurePage from './pages/DashboardConfigurePage' import GuidePage from './pages/GuidePage' import AdminTierLimitsPage from './pages/AdminTierLimitsPage' import AdminFeaturesPage from './pages/AdminFeaturesPage' @@ -34,6 +39,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminFocusAreasPage from './pages/AdminFocusAreasPage' +import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage' import AdminHomePage from './pages/AdminHomePage' import AdminUsersPage from './pages/AdminUsersPage' import AdminSystemPage from './pages/AdminSystemPage' @@ -224,13 +230,18 @@ function AppShell() { }/> }/> }/> - }/> + }> + } /> + } /> + } /> + }> }> } /> } /> } /> } /> + } /> }/> }/> }/> @@ -242,10 +253,13 @@ function AppShell() { }/> }/> }/> + }/> }/> }/> + } /> + } /> diff --git a/frontend/src/app.css b/frontend/src/app.css index 2ec9efb..9f4d219 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -552,6 +552,66 @@ a.analysis-split__nav-item { } } +/* Einstellungen: gleiche Split-Struktur wie Analyse/Admin */ +.settings-shell { + width: 100%; +} + +/* Referenzwerte: Übersichtskacheln (responsive, bis 4 Spalten Desktop) */ +.ref-value-tiles-grid { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +@media (min-width: 520px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 900px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1200px) { + .ref-value-tiles-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.ref-value-tile { + display: block; + width: 100%; + margin: 0; + padding: 14px 14px 12px; + text-align: left; + font-family: var(--font); + border-radius: 12px; + border: 1.5px solid var(--border2); + background: var(--surface); + color: var(--text1); + cursor: pointer; + box-sizing: border-box; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.ref-value-tile:hover { + border-color: var(--accent); +} + +.ref-value-tile:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.ref-value-tile--active { + border-color: var(--accent); + background: var(--surface2); +} + /* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */ .admin-shell { width: 100%; diff --git a/frontend/src/components/DashboardStatKit.jsx b/frontend/src/components/DashboardStatKit.jsx new file mode 100644 index 0000000..33b7d5d --- /dev/null +++ b/frontend/src/components/DashboardStatKit.jsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { + clampTileSpan, + DASHBOARD_TILE_GRID_COLS, +} from '../utils/dashboardLayout' + +export const PILL_TOOLTIPS = { + WHR: 'Waist-Hip-Ratio: Taille ÷ Hüfte. Maß für Bauchfettverteilung. Ziel: <0,90 (M) / <0,85 (F)', + WHtR: 'Waist-to-Height-Ratio: Taille ÷ Körpergröße. Gesündestest Maß: Ziel unter 0,50.', + KF: 'Körperfettanteil in Prozent (aus Caliper-Messung).', + 'Protein Ø7T': + 'Durchschnittliche tägliche Proteinaufnahme der letzten 7 Tage vs. Zielbereich (1,6–2,2g/kg KG).', +} + +export function Pill({ label, value, status, sub }) { + const [tip, setTip] = useState(false) + const color = status === 'good' ? 'var(--accent)' : status === 'warn' ? 'var(--warn)' : '#D85A30' + const bg = + status === 'good' + ? 'var(--accent-light)' + : status === 'warn' + ? 'var(--warn-bg)' + : '#FCEBEB' + const tipText = PILL_TOOLTIPS[label] + return ( +
+
tipText && setTip((s) => !s)} + onKeyDown={(e) => tipText && e.key === 'Enter' && setTip((s) => !s)} + tabIndex={tipText ? 0 : undefined} + style={{ + display: 'flex', + alignItems: 'center', + gap: 5, + padding: '5px 10px', + borderRadius: 20, + background: bg, + border: `1px solid ${color}44`, + cursor: tipText ? 'help' : 'default', + }} + > +
+ {label} + {value} + {sub && {sub}} + {tipText && ( + + ⓘ + + )} +
+ {tip && tipText && ( +
setTip(false)} + style={{ + position: 'absolute', + bottom: '110%', + left: 0, + zIndex: 50, + background: 'var(--surface)', + border: '1px solid var(--border)', + borderRadius: 8, + padding: '8px 10px', + fontSize: 11, + color: 'var(--text2)', + minWidth: 200, + maxWidth: 260, + lineHeight: 1.5, + boxShadow: '0 4px 16px rgba(0,0,0,0.15)', + }} + > + {label} +
+ {tipText} +
+ )} +
+ ) +} + +/** + * KPI-Kachel (Dashboard-Raster). + */ +export function StatCard({ + icon, + label, + value, + unit, + delta, + deltaGoodWhenNeg = false, + sub, + onClick, + color, + spanMobile = 1, + spanDesktop = 1, +}) { + const deltaColor = + delta == null + ? null + : (deltaGoodWhenNeg ? delta < 0 : delta > 0) + ? 'var(--accent)' + : 'var(--warn)' + const sm = clampTileSpan(spanMobile, DASHBOARD_TILE_GRID_COLS.mobile) + const lg = clampTileSpan(spanDesktop, DASHBOARD_TILE_GRID_COLS.desktop) + return ( +
onClick && (e.currentTarget.style.borderColor = 'var(--accent)')} + onMouseLeave={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--border)')} + > +
{icon}
+
{label}
+
+ {value} + {unit} +
+ {delta != null && ( +
+ {delta > 0 ? '+' : ''} + {delta} {unit} +
+ )} + {sub &&
{sub}
} +
+ ) +} diff --git a/frontend/src/components/QuickWeightEntry.jsx b/frontend/src/components/QuickWeightEntry.jsx new file mode 100644 index 0000000..9a05926 --- /dev/null +++ b/frontend/src/components/QuickWeightEntry.jsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react' +import { Check } from 'lucide-react' +import dayjs from 'dayjs' +import { api } from '../utils/api' + +/** + * Tagesgewicht erfassen (wie Dashboard „Gewicht heute“). + */ +export default function QuickWeightEntry({ onSaved }) { + const [input, setInput] = useState('') + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [weightUsage, setWeightUsage] = useState(null) + const today = dayjs().format('YYYY-MM-DD') + + const loadUsage = () => { + api + .getFeatureUsage() + .then((features) => { + const weightFeature = features.find((f) => f.feature_id === 'weight_entries') + setWeightUsage(weightFeature) + }) + .catch((err) => console.error('Failed to load usage:', err)) + } + + useEffect(() => { + api.weightStats().then((s) => { + if (s?.latest?.date === today) setInput(String(s.latest.weight)) + }) + loadUsage() + }, [today]) + + const handleSave = async () => { + const w = parseFloat(input) + if (!w || w < 20 || w > 300) return + setSaving(true) + setError(null) + try { + await api.upsertWeight(today, w) + setSaved(true) + await loadUsage() + onSaved?.() + setTimeout(() => setSaved(false), 2000) + } catch (err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(() => setError(null), 5000) + } finally { + setSaving(false) + } + } + + const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed) + const tooltipText = + weightUsage && !weightUsage.allowed + ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` + : '' + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isDisabled && handleSave()} + /> + kg +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/TrainingTypeDistribution.jsx b/frontend/src/components/TrainingTypeDistribution.jsx index 0c07780..271c3fc 100644 --- a/frontend/src/components/TrainingTypeDistribution.jsx +++ b/frontend/src/components/TrainingTypeDistribution.jsx @@ -15,8 +15,10 @@ export default function TrainingTypeDistribution({ days = 28 }) { const [loading, setLoading] = useState(true) useEffect(() => { + const safeDays = Math.max(1, Math.min(4000, Number(days) || 28)) + const limit = Math.min(50_000, Math.max(250, safeDays * 25)) Promise.all([ - api.listActivity(days), + api.listActivity(limit, safeDays), api.getTrainingCategories() ]).then(([activities, cats]) => { setCategories(cats) @@ -43,7 +45,7 @@ export default function TrainingTypeDistribution({ days = 28 }) { console.error('Failed to load training type distribution:', err) setLoading(false) }) - }, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes + }, [days, activeProfile?.quality_filter_level, activeProfile?.id]) if (loading) { return ( diff --git a/frontend/src/components/TrendKcalWeightChart.jsx b/frontend/src/components/TrendKcalWeightChart.jsx new file mode 100644 index 0000000..3637e25 --- /dev/null +++ b/frontend/src/components/TrendKcalWeightChart.jsx @@ -0,0 +1,183 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, +} from 'recharts' +import dayjs from 'dayjs' +import 'dayjs/locale/de' + +dayjs.locale('de') + +function rollingAvg(arr, key, w = 7) { + return arr.map((d, i) => { + const s = arr + .slice(Math.max(0, i - w + 1), i + 1) + .map((x) => x[key]) + .filter((v) => v != null) + return s.length + ? { + ...d, + [`${key}_avg`]: Math.round((s.reduce((a, b) => a + b) / s.length) * 10) / 10, + } + : d + }) +} + +/** + * Kalorien + Gewicht im Zeitfenster (wie Dashboard-Trends). + * @param {{ weights: any[], nutrition: any[], windowDays?: number }} props + */ +export default function TrendKcalWeightChart({ weights, nutrition, windowDays = 30 }) { + const n = Math.max(7, Math.min(90, Number(windowDays) || 30)) + const days = [] + for (let i = n - 1; i >= 0; i--) days.push(dayjs().subtract(i, 'day').format('YYYY-MM-DD')) + + const wMap = {} + ;(weights || []).forEach((w) => { + wMap[w.date] = w.weight + }) + const nMap = {} + ;(nutrition || []).forEach((x) => { + nMap[x.date] = Math.round(x.kcal || 0) + }) + + let lastW = null + const combined = days + .map((date) => { + if (wMap[date]) lastW = wMap[date] + return { + date: dayjs(date).format('DD.MM'), + kcal: nMap[date] || null, + weight: wMap[date] || null, + weightLine: lastW, + } + }) + .filter((d) => d.kcal || d.weightLine) + + const withAvg = rollingAvg(combined, 'kcal') + const hasKcal = combined.some((d) => d.kcal) + const hasW = combined.some((d) => d.weightLine) + + if (!hasKcal && !hasW) { + return ( +
+ Mehr Ernährungs- und Gewichtsdaten für den Chart nötig +
+ ) + } + + return ( + + + + + {hasKcal && ( + + )} + {hasW && ( + + )} + [ + v == null ? '–' : `${Math.round(v)} ${name === 'weightLine' || name === 'weight' ? 'kg' : 'kcal'}`, + name === 'kcal_avg' + ? 'Ø Kalorien (7T)' + : name === 'kcal' + ? 'Kalorien' + : name === 'weightLine' + ? 'Gewicht (interpoliert)' + : 'Gewicht Messung', + ]} + /> + {hasKcal && ( + + )} + {hasKcal && ( + + )} + {hasW && ( + + )} + {hasW && ( + { + const { cx, cy, value } = props + return value != null ? ( + + ) : ( + + ) + }} + connectNulls={false} + name="weight" + /> + )} + + + ) +} diff --git a/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx b/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx new file mode 100644 index 0000000..7619cfa --- /dev/null +++ b/frontend/src/components/dashboard-widgets/AiPipelineInsightWidget.jsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Brain } from 'lucide-react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { api } from '../../utils/api' +import Markdown from '../../utils/Markdown' + +dayjs.locale('de') + +export default function AiPipelineInsightWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const [insights, setInsights] = useState([]) + const [showInsight, setShowInsight] = useState(false) + const [pipelineLoading, setPipelineLoading] = useState(false) + const [pipelineError, setPipelineError] = useState(null) + + const load = () => + api.latestInsights().then((ins) => setInsights(Array.isArray(ins) ? ins : [])).catch(() => setInsights([])) + + useEffect(() => { + load() + }, [refreshTick]) + + const runPipeline = async () => { + setPipelineLoading(true) + setPipelineError(null) + try { + await api.insightPipeline() + await load() + } catch (e) { + setPipelineError(`Fehler: ${e.message}`) + } finally { + setPipelineLoading(false) + } + } + + const latestInsight = insights.find((i) => i.scope === 'gesamt') || insights[0] + + return ( +
+
+
KI-Auswertung
+ +
+ + {pipelineError &&
{pipelineError}
} + + {latestInsight ? ( + <> +
+ Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')} +
+
+ + {!showInsight && ( +
+ )} +
+ + + ) : ( +
+ Noch keine KI-Auswertung vorhanden. + +
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx b/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx new file mode 100644 index 0000000..f41e933 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/BodyStatStripWidget.jsx @@ -0,0 +1,108 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { StatCard } from '../DashboardStatKit' +import { dashboardStatGridClassName, DASHBOARD_TILE_GRID_COLS } from '../../utils/dashboardLayout' + +export default function BodyStatStripWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(60), api.listCaliper(3), api.listNutrition(30)]) + .then(([w, ca, n]) => { + setWeights(w) + setCalipers(ca) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + setNutrition([]) + }) + }, [refreshTick]) + + const latestW = weights[0] + const prevW = weights[1] + const latestCal = calipers[0] + const wDelta = latestW && prevW ? Math.round((latestW.weight - prevW.weight) * 10) / 10 : null + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + const bfPrev = calipers[1]?.body_fat_pct + const bfDelta = + latestCal?.body_fat_pct && bfPrev ? Math.round((latestCal.body_fat_pct - bfPrev) * 10) / 10 : null + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgKcal = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) + : null + + if (!latestW && !latestCal?.body_fat_pct && !avgKcal) { + return ( +
+ Noch keine Kennzahlen – erfasse Gewicht oder Körperdaten. +
+ ) + } + + return ( +
+
Kennzahlen
+
+ {latestW && ( + nav('/history')} + color="#378ADD" + /> + )} + {latestCal?.body_fat_pct != null && ( + nav('/history', { state: { tab: 'body' } })} + color={bfCat?.color} + /> + )} + {latestCal?.lean_mass != null && ( + nav('/history', { state: { tab: 'body' } })} + /> + )} + {avgKcal != null && ( + nav('/history', { state: { tab: 'nutrition' } })} + color="#EF9F27" + /> + )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx b/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx new file mode 100644 index 0000000..6d2bdb4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/DashboardGreetingWidget.jsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { useProfile } from '../../context/ProfileContext' +import { api } from '../../utils/api' + +dayjs.locale('de') + +/** Produkt-Dashboard: Begrüßung + Datum + letztes Gewicht-Datum */ +export default function DashboardGreetingWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const [latestWeightDate, setLatestWeightDate] = useState(null) + + useEffect(() => { + api + .listWeight(1) + .then((rows) => { + setLatestWeightDate(rows?.[0]?.date || null) + }) + .catch(() => setLatestWeightDate(null)) + }, [refreshTick]) + + return ( +
+

+ Hallo, {activeProfile?.name || 'Nutzer'} 👋 +

+
+ {dayjs().format('dddd, DD. MMMM YYYY')} + {latestWeightDate && ` · Letztes Update ${dayjs(latestWeightDate).format('DD.MM.')}`} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx b/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx new file mode 100644 index 0000000..ae27db4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/GoalsFocusTeaserWidget.jsx @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useProfile } from '../../context/ProfileContext' +import { api } from '../../utils/api' + +export default function GoalsFocusTeaserWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const { activeProfile } = useProfile() + const [goalsCount, setGoalsCount] = useState(null) + + useEffect(() => { + if (!activeProfile?.id) return + api + .listGoals() + .then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0)) + .catch(() => setGoalsCount(null)) + }, [activeProfile?.id, refreshTick]) + + return ( +
+
+
Ziele & Fokus
+ +
+
e.key === 'Enter' && nav('/goals')} + onClick={() => nav('/goals')} + > + {goalsCount != null && ( +
+ {goalsCount === 0 + ? 'Noch keine Ziele angelegt.' + : `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`} +
+ )} +
+ Focus Areas und Fortschritt – tippen zum Öffnen der Ziele-Seite. +
+
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx b/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx new file mode 100644 index 0000000..94d12fd --- /dev/null +++ b/frontend/src/components/dashboard-widgets/NutritionActivitySummaryWidget.jsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { + dashboardTileGridClassName, + DASHBOARD_TILE_GRID_COLS, +} from '../../utils/dashboardLayout' +import DashboardTile from '../DashboardTile' + +export default function NutritionActivitySummaryWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const [nutrition, setNutrition] = useState([]) + const [activities, setActivities] = useState([]) + const [latestWeight, setLatestWeight] = useState(null) + + useEffect(() => { + Promise.all([api.listNutrition(30), api.listActivity(800, 30), api.listWeight(1)]) + .then(([n, a, w]) => { + setNutrition(n) + setActivities(a) + setLatestWeight(w?.[0]?.weight ?? null) + }) + .catch(() => { + setNutrition([]) + setActivities([]) + setLatestWeight(null) + }) + }, [refreshTick]) + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgKcal = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) + : null + const avgProtein = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.protein_g || 0), 0) / recentNutr.length * 10) / 10 + : null + const ptLow = Math.round((latestWeight || 80) * 1.6) + const proteinOk = avgProtein && avgProtein >= ptLow + + const recentAct = activities.filter((a) => a.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const actKcal = recentAct.length ? Math.round(recentAct.reduce((s, a) => s + (a.kcal_active || 0), 0)) : null + + const showNutr = !!(avgKcal || avgProtein) + const showAct = actKcal != null + if (!showNutr && !showAct) { + return ( +
+ Noch keine Ernährungs- oder Aktivitätsdaten (7 Tage). +
+ ) + } + + const summaryBoth = showNutr && showAct + const summarySpanM = summaryBoth ? 1 : 2 + const summarySpanD = summaryBoth ? 2 : 4 + + return ( +
+
+ Ernährung & Aktivität +
+
+ {showNutr && ( + +
e.key === 'Enter' && nav('/history', { state: { tab: 'nutrition' } })} + onClick={() => nav('/history', { state: { tab: 'nutrition' } })} + > +
+ 🍽️ ERNÄHRUNG (Ø 7T) +
+ {avgKcal != null &&
{avgKcal} kcal
} + {avgProtein != null && ( +
+ {avgProtein}g Protein {proteinOk ? '✓' : '⚠️'} +
+ )} +
→ Verlauf Ernährung
+
+
+ )} + {showAct && ( + +
e.key === 'Enter' && nav('/history', { state: { tab: 'activity' } })} + onClick={() => nav('/history', { state: { tab: 'activity' } })} + > +
+ 🏋️ AKTIVITÄT (7T) +
+
{actKcal} kcal
+
{recentAct.length} Trainings
+
→ Verlauf Aktivität
+
+
+ )} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/NutritionDetailChartsWidget.jsx b/frontend/src/components/dashboard-widgets/NutritionDetailChartsWidget.jsx new file mode 100644 index 0000000..a3958cd --- /dev/null +++ b/frontend/src/components/dashboard-widgets/NutritionDetailChartsWidget.jsx @@ -0,0 +1,32 @@ +import { useNavigate } from 'react-router-dom' +import NutritionCharts from '../NutritionCharts' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * Phase-0c-Ernährungscharts (wie „Detaillierte Charts“ im Verlauf). + * @param {{ refreshTick?: number, chartDays?: number }} props + */ +export default function NutritionDetailChartsWidget({ refreshTick = 0, chartDays }) { + const nav = useNavigate() + const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 30 + + return ( +
+
+
+
Ernährung — Charts
+
API-Charts · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx b/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx new file mode 100644 index 0000000..d8fa644 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/ProfileGoalsProgressWidget.jsx @@ -0,0 +1,109 @@ +import { useEffect, useState } from 'react' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { api } from '../../utils/api' + +/** Profil-Ziele Gewicht / Körperfett (Balken wie Dashboard) */ +export default function ProfileGoalsProgressWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(120), api.listCaliper(3)]) + .then(([w, ca]) => { + setWeights(w) + setCalipers(ca) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + }) + }, [refreshTick]) + + const latestW = weights[0] + const latestCal = calipers[0] + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + + const gw = activeProfile?.goal_weight + const gbf = activeProfile?.goal_bf_pct + if ((!gw || !latestW) && (!gbf || latestCal?.body_fat_pct == null)) return null + + return ( +
+
Profil-Ziele
+ {gw && latestW && ( +
+ {(() => { + const start = Math.max(...weights.map((w) => w.weight)) + const curr = latestW.weight + const goal = gw + const total = start - goal + const done = start - curr + const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 100 + const remain = Math.round((curr - goal) * 10) / 10 + return ( + <> +
+ + Gewicht: {curr} → {goal} kg + + + {remain > 0 ? `noch ${remain}kg` : 'Ziel erreicht! 🎉'} + +
+
+
+
+
{pct}% des Weges
+ + ) + })()} +
+ )} + {gbf && latestCal?.body_fat_pct != null && ( +
+ {(() => { + const curr = latestCal.body_fat_pct + const goal = gbf + const remain = Math.round((curr - goal) * 10) / 10 + const pct = + curr <= goal ? 100 : Math.min(100, Math.round((1 - (curr - goal) / Math.max(curr - goal, 5)) * 100)) + return ( + <> +
+ + Körperfett: {curr}% → {goal}% + + + {remain > 0 ? `noch ${remain}%` : 'Ziel erreicht! 🎉'} + +
+
+
+
+
Aktuell: {bfCat?.label}
+ + ) + })()} +
+ )} +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx new file mode 100644 index 0000000..2ce4009 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/ProgressPhotosWidget.jsx @@ -0,0 +1,86 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../../utils/api' + +/** + * Fortschrittsfotos (Galerie wie Verlauf-Tab Fotos). + */ +export default function ProgressPhotosWidget({ refreshTick = 0 }) { + const nav = useNavigate() + const [photos, setPhotos] = useState([]) + const [big, setBig] = useState(null) + + useEffect(() => { + api.listPhotos().then(setPhotos).catch(() => setPhotos([])) + }, [refreshTick]) + + if (!photos.length) { + return ( +
+
Noch keine Fotos.
+ +
+ ) + } + + return ( +
+
+
Fortschrittsfotos
+ +
+ {big && ( +
setBig(null)} + > + +
+ )} +
+ {photos.map((p) => ( +
+ setBig(p.id)} + /> +
+ {p.date?.slice(0, 10) || p.created?.slice(0, 10)} +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx b/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx new file mode 100644 index 0000000..c46c9ff --- /dev/null +++ b/frontend/src/components/dashboard-widgets/QuickWeightTodayWidget.jsx @@ -0,0 +1,20 @@ +import { useNavigate } from 'react-router-dom' +import QuickWeightEntry from '../QuickWeightEntry' + +export default function QuickWeightTodayWidget({ onSaved }) { + const nav = useNavigate() + return ( +
+
+
+
Gewicht heute
+
Tageswert erfassen
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx new file mode 100644 index 0000000..4047427 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/RecoveryChartsPanelWidget.jsx @@ -0,0 +1,32 @@ +import { useNavigate } from 'react-router-dom' +import RecoveryCharts from '../RecoveryCharts' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * Erholung R1–R5 (wie Verlauf Erholung). + * @param {{ refreshTick?: number, chartDays?: number }} props + */ +export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }) { + const nav = useNavigate() + const days = chartDays != null ? normalizeBodyChartDays(chartDays) : 28 + + return ( +
+
+
+
Erholung — Charts
+
Schlaf, Recovery, Vitalwerte · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx b/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx new file mode 100644 index 0000000..17500b4 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/RecoverySleepRestWidget.jsx @@ -0,0 +1,20 @@ +import DashboardTile from '../DashboardTile' +import SleepWidget from '../SleepWidget' +import RestDaysWidget from '../RestDaysWidget' +import { dashboardTileGridClassName, DASHBOARD_TILE_GRID_COLS } from '../../utils/dashboardLayout' + +export default function RecoverySleepRestWidget() { + return ( +
+
Erholung
+
+ + + + + + +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx b/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx new file mode 100644 index 0000000..4db5fe2 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/StatusPillsWidget.jsx @@ -0,0 +1,89 @@ +import { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import { getBfCategory } from '../../utils/calc' +import { Pill } from '../DashboardStatKit' + +/** WHR, WHtR, Protein Ø7T, KF – wie Dashboard-Pill-Leiste */ +export default function StatusPillsWidget({ refreshTick = 0 }) { + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const height = activeProfile?.height || 178 + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + const [circs, setCircs] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(2), api.listCaliper(3), api.listCirc(2), api.listNutrition(30)]) + .then(([w, ca, ci, n]) => { + setWeights(w) + setCalipers(ca) + setCircs(ci) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setCalipers([]) + setCircs([]) + setNutrition([]) + }) + }, [refreshTick]) + + const latestCal = calipers[0] + const latestCir = circs[0] + const latestW = weights[0] + + const recentNutr = nutrition.filter((n) => n.date >= dayjs().subtract(7, 'day').format('YYYY-MM-DD')) + const avgProtein = recentNutr.length + ? Math.round(recentNutr.reduce((s, n) => s + (n.protein_g || 0), 0) / recentNutr.length * 10) / 10 + : null + const ptLow = Math.round((latestW?.weight || 80) * 1.6) + const proteinOk = avgProtein && avgProtein >= ptLow + + const whr = + latestCir?.c_waist && latestCir?.c_hip + ? Math.round((latestCir.c_waist / latestCir.c_hip) * 100) / 100 + : null + const whtr = + latestCir?.c_waist && height ? Math.round((latestCir.c_waist / height) * 100) / 100 : null + const bfCat = latestCal?.body_fat_pct ? getBfCategory(latestCal.body_fat_pct, sex) : null + + const pills = [] + if (whr) + pills.push({ + label: 'WHR', + value: whr, + status: whr < (sex === 'm' ? 0.9 : 0.85) ? 'good' : 'warn', + sub: `<${sex === 'm' ? '0,90' : '0,85'}`, + }) + if (whtr) pills.push({ label: 'WHtR', value: whtr, status: whtr < 0.5 ? 'good' : 'warn', sub: '<0,50' }) + if (avgProtein) + pills.push({ + label: 'Protein Ø7T', + value: `${avgProtein}g`, + status: proteinOk ? 'good' : 'warn', + sub: `Ziel ${ptLow}g`, + }) + if (bfCat && latestCal?.body_fat_pct != null) + pills.push({ + label: 'KF', + value: `${latestCal.body_fat_pct}%`, + status: latestCal.body_fat_pct < (sex === 'm' ? 18 : 25) ? 'good' : 'warn', + sub: bfCat.label, + }) + + if (pills.length === 0) return null + + return ( +
+
Indikatoren
+
+ {pills.map((p, i) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx b/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx new file mode 100644 index 0000000..754aefd --- /dev/null +++ b/frontend/src/components/dashboard-widgets/TrendKcalWeightWidget.jsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { api } from '../../utils/api' +import TrendKcalWeightChart from '../TrendKcalWeightChart' +import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays' + +/** + * @param {{ refreshTick?: number, chartDays?: number }} props + */ +export default function TrendKcalWeightWidget({ refreshTick = 0, chartDays }) { + const nav = useNavigate() + const windowDays = chartDays != null ? normalizeBodyChartDays(chartDays) : 30 + const fetchNutritionDays = Math.max(windowDays, 30) + const [weights, setWeights] = useState([]) + const [nutrition, setNutrition] = useState([]) + + useEffect(() => { + Promise.all([api.listWeight(Math.max(60, windowDays + 30)), api.listNutrition(fetchNutritionDays)]) + .then(([w, n]) => { + setWeights(w) + setNutrition(n) + }) + .catch(() => { + setWeights([]) + setNutrition([]) + }) + }, [refreshTick, windowDays, fetchNutritionDays]) + + if (weights.length <= 2 && nutrition.length <= 2) { + return ( +
+ Mehr Gewichts- und Ernährungsdaten für den Trend nötig. +
+ ) + } + + return ( +
+
+
+
Trends
+
+ Kalorien und Gewicht ({windowDays} Tage) +
+
+ +
+ +
+ + + Ø Kalorien + + + + Gewicht + +
+
+ ) +} diff --git a/frontend/src/components/pilot/PilotActivitySection.jsx b/frontend/src/components/pilot/PilotActivitySection.jsx new file mode 100644 index 0000000..f4e23a6 --- /dev/null +++ b/frontend/src/components/pilot/PilotActivitySection.jsx @@ -0,0 +1,132 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import TrainingTypeDistribution from '../TrainingTypeDistribution' +import { + BODY_CHART_DAYS_DEFAULT, + normalizeBodyChartDays, +} from '../../widgetSystem/bodyChartDays' +import PilotRuleCard from './PilotRuleCard' + +export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) { + const periodDays = normalizeBodyChartDays(chartDays) + const { activeProfile } = useProfile() + const globalQualityLevel = activeProfile?.quality_filter_level + const [activities, setActivities] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const limit = Math.min(50_000, Math.max(200, periodDays * 25)) + const a = await api.listActivity(limit, periodDays) + if (!cancelled) setActivities(Array.isArray(a) ? a : []) + } catch { + if (!cancelled) setActivities([]) + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [refreshTick, globalQualityLevel, periodDays]) + + const cutoff = dayjs().subtract(periodDays, 'day').format('YYYY-MM-DD') + const filtA = (activities || []).filter((d) => d.date >= cutoff) + + const daysWithAct = new Set(filtA.map((a) => a.date)).size + const totalDays = + filtA.length > 0 + ? Math.min(periodDays, dayjs().diff(dayjs(filtA[filtA.length - 1]?.date), 'day') + 1) + : 0 + const consistency = totalDays > 0 ? Math.round((daysWithAct / totalDays) * 100) : 0 + + const actRules = [ + { + status: consistency >= 70 ? 'good' : consistency >= 40 ? 'warn' : 'bad', + icon: '📅', + category: 'Konsistenz', + title: `${consistency}% aktive Tage (${daysWithAct}/${Math.min(periodDays, totalDays || periodDays)} Tage)`, + detail: + consistency >= 70 + ? 'Ausgezeichnete Regelmäßigkeit.' + : consistency >= 40 + ? 'Ziel: 4–5 Einheiten/Woche.' + : 'Mehr Regelmäßigkeit empfohlen.', + value: `${consistency}%`, + }, + ] + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

Bereich Aktivität

+

+ Trainingstyp-Verteilung {periodDays} Tage · Bewertung Konsistenz wie im Verlauf +

+
+ + {globalQualityLevel && globalQualityLevel !== 'all' && ( +
+ Aktiver Qualitätsfilter im Profil – Aktivitätsdaten entsprechend gefiltert. + + Einstellungen + +
+ )} + +
+
Trainingstyp-Verteilung
+ +
+ + Vollständiger Verlauf Aktivität → + +
+
+ +
+
Bewertung · Aktivität
+ {filtA.length === 0 ? ( +

+ Noch keine Aktivitäten.{' '} + + Training erfassen + +

+ ) : ( + actRules.map((item, i) => ) + )} +
+
+ ) +} diff --git a/frontend/src/components/pilot/PilotBodySection.jsx b/frontend/src/components/pilot/PilotBodySection.jsx new file mode 100644 index 0000000..18a29f6 --- /dev/null +++ b/frontend/src/components/pilot/PilotBodySection.jsx @@ -0,0 +1,230 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + ReferenceLine, +} from 'recharts' +import { ChevronRight } from 'lucide-react' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { useProfile } from '../../context/ProfileContext' +import { getInterpretation } from '../../utils/interpret' +import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils' +import { + BODY_CHART_DAYS_DEFAULT, + normalizeBodyChartDays, +} from '../../widgetSystem/bodyChartDays' +import PilotRuleCard from './PilotRuleCard' + +export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) { + const windowDays = normalizeBodyChartDays(chartDays) + const { activeProfile } = useProfile() + const [weights, setWeights] = useState([]) + const [calipers, setCalipers] = useState([]) + const [circs, setCircs] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const fetchDays = Math.max(120, windowDays + 60) + const [w, ca, ci] = await Promise.all([ + api.listWeight(fetchDays), + api.listCaliper(Math.max(30, windowDays)), + api.listCirc(Math.max(30, windowDays)), + ]) + if (!cancelled) { + setWeights(Array.isArray(w) ? w : []) + setCalipers(Array.isArray(ca) ? ca : []) + setCircs(Array.isArray(ci) ? ci : []) + } + } catch { + if (!cancelled) { + setWeights([]) + setCalipers([]) + setCircs([]) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [refreshTick, windowDays]) + + const cutoff = dayjs().subtract(windowDays, 'day').format('YYYY-MM-DD') + + const filtW = [...(weights || [])] + .sort((a, b) => a.date.localeCompare(b.date)) + .filter((d) => d.date >= cutoff) + const filtCal = (calipers || []).filter((d) => d.date >= cutoff) + const filtCir = (circs || []).filter((d) => d.date >= cutoff) + + const hasWeight = filtW.length >= 2 + const latestCal = filtCal[0] + const prevCal = filtCal[1] + const latestCir = filtCir[0] + const latestW2 = filtW[filtW.length - 1] + + const withAvg = rollingAvg(filtW, 'weight', 7) + const withAvg14 = rollingAvg(filtW, 'weight', 14) + const wCd = withAvg.map((d, i) => ({ + date: fmtDate(d.date), + weight: d.weight, + avg7: d.weight_avg, + avg14: withAvg14[i]?.weight_avg, + })) + const ws = filtW.map((w) => w.weight) + const avgAll = ws.length ? Math.round((ws.reduce((a, b) => a + b, 0) / ws.length) * 10) / 10 : null + + const combined = { + ...(latestCal || {}), + c_waist: latestCir?.c_waist, + c_hip: latestCir?.c_hip, + weight: latestW2?.weight, + } + const rules = getInterpretation(combined, activeProfile || {}, prevCal || null) + + if (loading) { + return ( +
+
+
+ ) + } + + return ( +
+
+

Bereich Körper

+

+ Fokus letzte {windowDays} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf +

+
+ + {!hasWeight && ( +
+ Zu wenig Gewichtsdaten für den Graph.{' '} + + Gewicht erfassen + +
+ )} + + {hasWeight && ( +
+
+
+ Gewicht · {filtW.length} Messungen ({windowDays}T) +
+ + Verlauf Körper + +
+ + + + + + {avgAll && ( + + )} + {activeProfile?.goal_weight && ( + + )} + [ + `${v} kg`, + n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage', + ]} + /> + + + + + +
+ + + Täglich + + + + Ø 7T + + + + + + Ø 14T + + + + Ø Zeitraum + +
+
+ )} + + {rules.length > 0 && ( +
+
Bewertung · Körper
+

+ Körperfett, Magermasse (FFMI), BMI – gleiche Logik wie auf der Verlauf-Seite (Körper). +

+ {rules.map((item, i) => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/pilot/PilotKpiBoard.jsx new file mode 100644 index 0000000..02b3898 --- /dev/null +++ b/frontend/src/components/pilot/PilotKpiBoard.jsx @@ -0,0 +1,222 @@ +import { useState, useEffect, useMemo, useCallback } from 'react' +import { Link } from 'react-router-dom' +import dayjs from 'dayjs' +import { api } from '../../utils/api' +import { getBfCategory } from '../../utils/calc' +import { useProfile } from '../../context/ProfileContext' +import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays' +import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles' + +const MAX_KPI = 9 + +function formatRefVal(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) : '–' +} + +function parseRefTypeKey(tileId) { + if (!tileId.startsWith('ref:')) return null + return tileId.slice(4) || null +} + +function buildAutoTileIds(refTiles, hasBf, hasKcal) { + const ids = [] + for (const t of refTiles) { + if (t?.type_key) ids.push(`ref:${t.type_key}`) + } + if (hasBf) ids.push('body_fat') + if (hasKcal) ids.push('avg_kcal') + return ids.slice(0, MAX_KPI) +} + +/** + * KPIs: Referenzwerte, Körperfett, Ø Kalorien — max. 9 Kacheln. + * @param {{ refreshTick?: number, kpiConfig?: Record }} props + * kpiConfig.tiles: geordnete Kachel-ids; fehlend = automatische Belegung (wie bisher). + */ +export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) { + const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig]) + + const { activeProfile } = useProfile() + const sex = activeProfile?.sex || 'm' + const [refTiles, setRefTiles] = useState([]) + const [refByKey, setRefByKey] = useState(() => new Map()) + const [bf, setBf] = useState(null) + const [avgKcal, setAvgKcal] = useState(null) + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + setLoading(true) + const kcalDays = KPI_KCAL_WINDOW_DEFAULT + const nutrLimit = Math.min(2000, Math.max(60, kcalDays * 5)) + const [summary, calipers, nutrition] = await Promise.all([ + api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })), + api.listCaliper(3).catch(() => []), + api.listNutrition(nutrLimit).catch(() => []), + ]) + if (cancelled) return + const tiles = Array.isArray(summary?.tiles) ? summary.tiles.filter((t) => t?.latest) : [] + const map = new Map(tiles.map((t) => [t.type_key, t])) + const latestCal = Array.isArray(calipers) && calipers[0]?.body_fat_pct != null ? calipers[0] : null + const recentNutr = (nutrition || []).filter( + (n) => n.date >= dayjs().subtract(kcalDays, 'day').format('YYYY-MM-DD'), + ) + const kcal = + recentNutr.length > 0 + ? Math.round(recentNutr.reduce((s, n) => s + (n.kcal || 0), 0) / recentNutr.length) + : null + + const wantBf = !!latestCal?.body_fat_pct + const wantKcal = kcal != null && kcal > 0 + + setRefTiles(tiles) + setRefByKey(map) + setBf( + wantBf + ? { + pct: latestCal.body_fat_pct, + cat: getBfCategory(latestCal.body_fat_pct, sex), + date: latestCal.date, + } + : null, + ) + setAvgKcal(wantKcal ? kcal : null) + setErr(null) + } catch (e) { + if (!cancelled) { + setErr(e.message || 'KPIs konnten nicht geladen werden') + setRefTiles([]) + setRefByKey(new Map()) + } + } finally { + if (!cancelled) setLoading(false) + } + })() + return () => { + cancelled = true + } + }, [refreshTick, sex]) + + const orderIds = useMemo(() => { + if (manualOrder !== undefined) { + return manualOrder + } + const hasBf = !!bf + const hasKcal = avgKcal != null && avgKcal > 0 + return buildAutoTileIds(refTiles, hasBf, hasKcal) + }, [manualOrder, refTiles, bf, avgKcal]) + + const pushTileForId = useCallback( + (id, out) => { + if (id === 'body_fat') { + if (!bf) return + out.push( +
+
Körperfett
+
+ {bf.pct}% +
+
{bf.cat?.label || 'Caliper'}
+
, + ) + return + } + if (id === 'avg_kcal') { + if (avgKcal == null) return + out.push( +
+
+ Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T) +
+
{avgKcal} kcal
+
Ernährung
+
, + ) + return + } + const tk = parseRefTypeKey(id) + if (!tk) return + const tile = refByKey.get(tk) + if (!tile?.latest) return + const l = tile.latest + out.push( +
+
{tile.type_label}
+
+ {formatRefVal(l)} + {l.unit ? ( + {l.unit} + ) : null} +
+
Ref.wert
+
, + ) + }, + [bf, avgKcal, refByKey], + ) + + const visibleTiles = useMemo(() => { + const out = [] + for (const id of orderIds) { + pushTileForId(id, out) + } + return out + }, [orderIds, pushTileForId]) + + if (loading) { + return ( +
+
+
+ ) + } + if (err) { + return ( +
+ {err} +
+ ) + } + + if (visibleTiles.length === 0) { + return ( +
+
Kennzahlen
+

+ Noch keine Daten oder keine passenden Kacheln.{' '} + + Referenzwerte + + ,{' '} + + Caliper + + ,{' '} + + Ernährung + + . +

+
+ ) + } + + return ( +
+
Kennzahlen
+

+ {manualOrder !== undefined + ? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).' + : `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`} +

+
{visibleTiles}
+
+ ) +} diff --git a/frontend/src/components/pilot/PilotQuickCapture.jsx b/frontend/src/components/pilot/PilotQuickCapture.jsx new file mode 100644 index 0000000..2854bee --- /dev/null +++ b/frontend/src/components/pilot/PilotQuickCapture.jsx @@ -0,0 +1,278 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { Check } from 'lucide-react' +import dayjs from 'dayjs' +import { api } from '../../utils/api' + +/** + * Schnelleingabe: Gewicht + Baseline Vitals (Ruhepuls, HRV, VO₂max) für heute. + * @param {{ onSaved?: () => void, captureConfig?: Record }} props + * captureConfig: show_weight, show_resting_hr, show_hrv, show_vo2_max (false = ausblenden; fehlend = true) + */ +export default function PilotQuickCapture({ onSaved, captureConfig }) { + const cfgRaw = captureConfig && typeof captureConfig === 'object' ? captureConfig : {} + const showWeight = cfgRaw.show_weight !== false + const showRestingHr = cfgRaw.show_resting_hr !== false + const showHrv = cfgRaw.show_hrv !== false + const showVo2 = cfgRaw.show_vo2_max !== false + const showVitalsBlock = showRestingHr || showHrv || showVo2 + const today = dayjs().format('YYYY-MM-DD') + const [weightInput, setWeightInput] = useState('') + const [weightSaving, setWeightSaving] = useState(false) + const [weightSaved, setWeightSaved] = useState(false) + const [weightErr, setWeightErr] = useState(null) + + const [vForm, setVForm] = useState({ + id: null, + resting_hr: '', + hrv: '', + vo2_max: '', + }) + const [vSaving, setVSaving] = useState(false) + const [vErr, setVErr] = useState(null) + const [vOk, setVOk] = useState(false) + + useEffect(() => { + api.weightStats().then((s) => { + if (s?.latest?.date === today) setWeightInput(String(s.latest.weight)) + }).catch(() => {}) + }, [today]) + + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const existing = await api.getBaselineByDate(today) + if (cancelled || !existing?.id) return + setVForm({ + id: existing.id, + resting_hr: existing.resting_hr != null ? String(existing.resting_hr) : '', + hrv: existing.hrv != null ? String(existing.hrv) : '', + vo2_max: existing.vo2_max != null ? String(existing.vo2_max) : '', + }) + } catch (err) { + const msg = String(err?.message || '') + if (msg.includes('404') || msg.toLowerCase().includes('nicht gefunden')) { + setVForm((f) => ({ ...f, id: null, resting_hr: '', hrv: '', vo2_max: '' })) + } + } + })() + return () => { + cancelled = true + } + }, [today]) + + const saveWeight = async () => { + const w = parseFloat(weightInput) + if (!w || w < 20 || w > 300) return + setWeightSaving(true) + setWeightErr(null) + try { + await api.upsertWeight(today, w) + setWeightSaved(true) + onSaved?.() + setTimeout(() => setWeightSaved(false), 2000) + } catch (e) { + setWeightErr(e.message || 'Fehler') + } finally { + setWeightSaving(false) + } + } + + const saveVitals = async () => { + setVSaving(true) + setVErr(null) + setVOk(false) + try { + const payload = { date: today } + if (showRestingHr && vForm.resting_hr) payload.resting_hr = parseInt(vForm.resting_hr, 10) + if (showHrv && vForm.hrv) payload.hrv = parseInt(vForm.hrv, 10) + if (showVo2 && vForm.vo2_max) payload.vo2_max = parseFloat(vForm.vo2_max) + + if (!payload.resting_hr && !payload.hrv && !payload.vo2_max) { + const hint = [showRestingHr && 'Ruhepuls', showHrv && 'HRV', showVo2 && 'VO₂max'].filter(Boolean).join(', ') + setVErr( + hint + ? `Mindestens einen sichtbaren Wert angeben (${hint}).` + : 'Keine Vitalfelder sichtbar.' + ) + setVSaving(false) + return + } + + if (vForm.id) { + await api.updateBaseline(vForm.id, payload) + } else { + const created = await api.createBaseline(payload) + if (created?.id) setVForm((f) => ({ ...f, id: created.id })) + } + setVOk(true) + onSaved?.() + setTimeout(() => setVOk(false), 2000) + } catch (e) { + setVErr(e.message || 'Speichern fehlgeschlagen') + } finally { + setVSaving(false) + } + } + + const cellStyle = { + flex: '1 1 140px', + minWidth: 0, + padding: 12, + borderRadius: 10, + border: '1px solid var(--border)', + background: 'var(--surface2)', + } + + if (!showWeight && !showVitalsBlock) { + return ( +
+
Schnelleingabe (heute)
+

+ Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen + oder Vitalwerte-Seite nutzen. +

+
+ ) + } + + return ( +
+
Schnelleingabe (heute)
+ {(showWeight || showVitalsBlock) && ( +

+ {showWeight && showVitalsBlock && 'Gewicht separat; Vitalwerte typischerweise gemeinsam. '} + {showWeight && !showVitalsBlock && 'Gewicht für heute. '} + {!showWeight && showVitalsBlock && 'Baseline-Vitalwerte für heute. '} + + Volle Vitalwerte-Seite → + +

+ )} + +
+ {showWeight && ( +
+
Gewicht
+ {weightErr && ( +
{weightErr}
+ )} +
+ setWeightInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveWeight()} + /> + +
+
+ )} + + {showVitalsBlock && ( +
+
+ Vitalwerte (Baseline) +
+ {vErr &&
{vErr}
} +
+ {showRestingHr && ( +
+ + setVForm((f) => ({ ...f, resting_hr: e.target.value }))} + /> +
+ )} + {showHrv && ( +
+ + setVForm((f) => ({ ...f, hrv: e.target.value }))} + /> +
+ )} + {showVo2 && ( +
+ + setVForm((f) => ({ ...f, vo2_max: e.target.value }))} + /> +
+ )} +
+ +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/pilot/PilotRuleCard.jsx b/frontend/src/components/pilot/PilotRuleCard.jsx new file mode 100644 index 0000000..55bcc6f --- /dev/null +++ b/frontend/src/components/pilot/PilotRuleCard.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { ChevronDown, ChevronUp } from 'lucide-react' +import { getStatusColor, getStatusBg } from '../../utils/interpret' + +export default function PilotRuleCard({ item }) { + const [open, setOpen] = useState(false) + const color = getStatusColor(item.status) + return ( +
+ + {open && ( +
+ {item.detail} +
+ )} +
+ ) +} diff --git a/frontend/src/components/pilot/PilotWelcome.jsx b/frontend/src/components/pilot/PilotWelcome.jsx new file mode 100644 index 0000000..fa991b7 --- /dev/null +++ b/frontend/src/components/pilot/PilotWelcome.jsx @@ -0,0 +1,19 @@ +import dayjs from 'dayjs' +import 'dayjs/locale/de' +import { useProfile } from '../../context/ProfileContext' + +dayjs.locale('de') + +export default function PilotWelcome() { + const { activeProfile } = useProfile() + return ( +
+

+ Hallo, {activeProfile?.name || 'Nutzer'} 👋 +

+

+ {dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht +

+
+ ) +} diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 80da080..bb5cbc2 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -86,6 +86,11 @@ export const ADMIN_GROUPS = [ label: 'Focus Areas', description: 'Dynamische Fokusbereiche und Kategorien.', }, + { + to: '/admin/reference-value-types', + label: 'Referenz-Kennwerte', + description: 'Typen für persönliche Referenzwerte (Schlüssel, Namen, Metadaten).', + }, ], }, { @@ -110,6 +115,11 @@ export const ADMIN_GROUPS = [ label: 'SMTP & Metadaten-Export', description: 'SMTP-Status, Test-Mail und Placeholder-Katalog (JSON/ZIP).', }, + { + to: '/admin/dashboard-product-default', + label: 'Produkt-Dashboard (Standard)', + description: 'Globales Standard-Layout der Startseite (DB oder Code-Fallback).', + }, ], }, ] diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js new file mode 100644 index 0000000..f03cc3f --- /dev/null +++ b/frontend/src/config/settingsNav.js @@ -0,0 +1,10 @@ +/** + * Einstellungen: Sub-Navigation (Mobil = Chip-Leiste, Desktop = linke Spalte). + * Pfade müssen mit den Routes unter SettingsShell in App.jsx übereinstimmen. + */ + +export const SETTINGS_SHELL_NAV_ITEMS = [ + { id: 'general', label: 'Allgemein', to: '/settings', end: true }, + { id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' }, + { id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' }, +] diff --git a/frontend/src/layouts/SettingsShell.jsx b/frontend/src/layouts/SettingsShell.jsx new file mode 100644 index 0000000..10463f3 --- /dev/null +++ b/frontend/src/layouts/SettingsShell.jsx @@ -0,0 +1,33 @@ +import { Outlet, NavLink } from 'react-router-dom' +import { SETTINGS_SHELL_NAV_ITEMS } from '../config/settingsNav' + +/** + * Wie Admin / KI-Analyse: Chips horizontal (mobil) bzw. Seitenleiste (Desktop). + */ +export default function SettingsShell() { + return ( +
+
+
+ +
+
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/AdminFocusAreasPage.jsx b/frontend/src/pages/AdminFocusAreasPage.jsx index f28df7e..a68c462 100644 --- a/frontend/src/pages/AdminFocusAreasPage.jsx +++ b/frontend/src/pages/AdminFocusAreasPage.jsx @@ -14,8 +14,19 @@ const CATEGORIES = [ { value: 'custom', label: 'Eigene' } ] +function groupAreasByCategory(areas) { + const grouped = {} + for (const area of areas) { + const cat = area.category || 'other' + if (!grouped[cat]) grouped[cat] = [] + grouped[cat].push(area) + } + return grouped +} + export default function AdminFocusAreasPage() { const [data, setData] = useState({ areas: [], grouped: {}, total: 0 }) + const [usageTypesCatalog, setUsageTypesCatalog] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showInactive, setShowInactive] = useState(false) @@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() { loadData() }, [showInactive]) + useEffect(() => { + let cancelled = false + ;(async () => { + try { + const r = await api.listFocusAreaUsageTypes() + if (!cancelled) setUsageTypesCatalog(r.usage_types || []) + } catch (e) { + console.error('usage-types catalog:', e) + if (!cancelled) setUsageTypesCatalog([]) + } + })() + return () => { cancelled = true } + }, []) + const loadData = async () => { try { setLoading(true) const result = await api.listFocusAreaDefinitions(showInactive) - setData(result) + const areas = (result.areas || []).map(a => ({ + ...a, + allowed_usage_type_keys: Array.isArray(a.allowed_usage_type_keys) + ? a.allowed_usage_type_keys + : [] + })) + setData({ + areas, + grouped: groupAreasByCategory(areas), + total: result.total ?? areas.length + }) setError(null) } catch (err) { console.error('Failed to load focus areas:', err) @@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() { const handleUpdate = async (id) => { try { const area = data.areas.find(a => a.id === id) - await api.updateFocusAreaDefinition(id, { - name_de: area.name_de, - name_en: area.name_en, - icon: area.icon, - description: area.description, - category: area.category, - is_active: area.is_active - }) + const usageKeys = Array.isArray(area.allowed_usage_type_keys) + ? area.allowed_usage_type_keys + : [] + await Promise.all([ + api.updateFocusAreaDefinition(id, { + name_de: area.name_de, + name_en: area.name_en, + icon: area.icon, + description: area.description, + category: area.category, + is_active: area.is_active + }), + api.setFocusAreaUsageTypes(id, usageKeys) + ]) setEditingId(null) await loadData() } catch (err) { @@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() { } const updateField = (id, field, value) => { - setData(prev => ({ - ...prev, - areas: prev.areas.map(a => + setData(prev => { + const areas = prev.areas.map(a => a.id === id ? { ...a, [field]: value } : a ) - })) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) + } + + const toggleUsageTypeKey = (areaId, key, checked) => { + setData(prev => { + const areas = prev.areas.map(a => { + if (a.id !== areaId) return a + const cur = new Set(Array.isArray(a.allowed_usage_type_keys) ? a.allowed_usage_type_keys : []) + if (checked) cur.add(key) + else cur.delete(key) + return { ...a, allowed_usage_type_keys: [...cur] } + }) + return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length } + }) } if (loading) { @@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() { />
+
+ + {usageTypesCatalog.length === 0 ? ( + + Kein Katalog geladen (Backend / Migration prüfen). + + ) : ( +
+ {usageTypesCatalog.map(ut => ( + + ))} +
+ )} +
+
diff --git a/frontend/src/pages/AdminReferenceValueTypesPage.jsx b/frontend/src/pages/AdminReferenceValueTypesPage.jsx new file mode 100644 index 0000000..ab631f4 --- /dev/null +++ b/frontend/src/pages/AdminReferenceValueTypesPage.jsx @@ -0,0 +1,607 @@ +import { useState, useEffect, useCallback } from 'react' +import { Link } from 'react-router-dom' +import { Gauge, Plus, Pencil, Trash2, Save, X, ChevronUp, ChevronDown } from 'lucide-react' +import { api } from '../utils/api' +import { VALUE_DATA_TYPE_LABELS } from '../utils/referenceValueMeta' + +const VALUE_TYPES = ['integer', 'decimal', 'percentage', 'text', 'enum'] + +function buildValidationRules(form) { + const t = form.value_data_type + if (t === 'integer' || t === 'decimal') { + const r = { positive_only: !!form.vr_positive_only } + if (form.vr_min !== '' && !Number.isNaN(Number(form.vr_min))) r.min = Number(form.vr_min) + if (form.vr_max !== '' && !Number.isNaN(Number(form.vr_max))) r.max = Number(form.vr_max) + return r + } + if (t === 'percentage') { + const r = { positive_only: !!form.vr_positive_only } + if (form.vr_min !== '' && !Number.isNaN(Number(form.vr_min))) r.min = Number(form.vr_min) + if (form.vr_max !== '' && !Number.isNaN(Number(form.vr_max))) r.max = Number(form.vr_max) + return r + } + if (t === 'text') { + const r = { not_empty: !!form.vr_not_empty } + if (form.vr_max_length !== '' && !Number.isNaN(parseInt(form.vr_max_length, 10))) { + r.max_length = parseInt(form.vr_max_length, 10) + } + return r + } + if (t === 'enum') { + const parts = form.vr_enum_list.split(',').map((s) => s.trim()).filter(Boolean) + return { allowed_values: parts } + } + return {} +} + +const emptyForm = () => ({ + key: '', + label: '', + category: '', + description: '', + default_unit: '', + value_data_type: 'decimal', + vr_min: '', + vr_max: '', + vr_positive_only: false, + vr_max_length: '', + vr_not_empty: true, + vr_enum_list: '', + active: true, + metadata_json: '{}', +}) + +export default function AdminReferenceValueTypesPage() { + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [reorderBusy, setReorderBusy] = useState(false) + const [error, setError] = useState(null) + const [toast, setToast] = useState(null) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(emptyForm()) + const [showForm, setShowForm] = useState(false) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await api.adminListReferenceValueTypes() + setRows(Array.isArray(data) ? data : []) + } catch (e) { + setError(e.message || 'Laden fehlgeschlagen') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const showToast = (msg) => { + setToast(msg) + setTimeout(() => setToast(null), 2500) + } + + const openCreate = () => { + setEditingId(null) + setForm(emptyForm()) + setShowForm(true) + } + + const openEdit = (r) => { + const vr = r.validation_rules && typeof r.validation_rules === 'object' ? r.validation_rules : {} + const allowed = Array.isArray(vr.allowed_values) ? vr.allowed_values.join(', ') : '' + setEditingId(r.id) + setForm({ + key: r.key || '', + label: r.label || '', + category: r.category || '', + description: r.description || '', + default_unit: r.default_unit || '', + value_data_type: r.value_data_type || 'decimal', + vr_min: vr.min != null ? String(vr.min) : '', + vr_max: vr.max != null ? String(vr.max) : '', + vr_positive_only: !!vr.positive_only, + vr_max_length: vr.max_length != null ? String(vr.max_length) : '', + vr_not_empty: vr.not_empty !== false, + vr_enum_list: allowed, + active: !!r.active, + metadata_json: r.metadata && typeof r.metadata === 'object' ? JSON.stringify(r.metadata, null, 2) : '{}', + }) + setShowForm(true) + } + + const closeForm = () => { + setShowForm(false) + setEditingId(null) + setForm(emptyForm()) + } + + const handleSave = async () => { + if (!form.label.trim()) { + setError('Bitte einen Anzeigenamen (label) angeben.') + return + } + if (!form.default_unit.trim()) { + setError('Standard-Einheit ist erforderlich (bei Nutzern nicht änderbar).') + return + } + if (form.value_data_type === 'enum' && !form.vr_enum_list.trim()) { + setError('Bei Datentyp „Auswahl (ENUM)“ bitte erlaubte Werte (kommagetrennt) eintragen.') + return + } + + let metadata = {} + try { + const mj = form.metadata_json.trim() || '{}' + metadata = JSON.parse(mj) + if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) { + setError('Zusatz-Metadaten müssen ein JSON-Objekt sein.') + return + } + } catch { + setError('Zusatz-Metadaten: ungültiges JSON.') + return + } + + const validation_rules = buildValidationRules(form) + + const payload = { + label: form.label.trim(), + category: form.category.trim() || null, + description: form.description.trim() || null, + default_unit: form.default_unit.trim(), + value_data_type: form.value_data_type, + validation_rules, + active: !!form.active, + metadata, + } + + try { + setError(null) + if (editingId) { + await api.adminUpdateReferenceValueType(editingId, payload) + showToast('Typ gespeichert') + } else { + if (!form.key.trim()) { + setError('Bitte einen technischen Schlüssel (key) angeben.') + return + } + await api.adminCreateReferenceValueType({ + key: form.key.trim().toLowerCase(), + ...payload, + }) + showToast('Typ angelegt') + } + closeForm() + await load() + } catch (e) { + setError(e.message || 'Speichern fehlgeschlagen') + } + } + + const handleDelete = async (r) => { + if ( + !confirm( + `Typ „${r.label}“ (${r.key}) wirklich löschen? Nur möglich, wenn keine Nutzer-Einträge existieren.`, + ) + ) { + return + } + try { + setError(null) + await api.adminDeleteReferenceValueType(r.id) + showToast('Typ gelöscht') + await load() + } catch (e) { + setError(e.message || 'Löschen fehlgeschlagen') + } + } + + const moveRow = async (index, dir) => { + const j = dir === 'up' ? index - 1 : index + 1 + if (j < 0 || j >= rows.length) return + setReorderBusy(true) + setError(null) + const next = [...rows] + ;[next[index], next[j]] = [next[j], next[index]] + try { + await api.adminReorderReferenceValueTypes(next.map((r) => r.id)) + setRows(next) + } catch (e) { + setError(e.message || 'Reihenfolge konnte nicht gespeichert werden') + await load() + } finally { + setReorderBusy(false) + } + } + + const plausibilisierungBlock = () => { + const t = form.value_data_type + if (t === 'integer' || t === 'decimal' || t === 'percentage') { + return ( + <> +
+
+ + setForm((f) => ({ ...f, vr_min: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, vr_max: e.target.value }))} + /> +
+
+
+ Optionen + +
+ + ) + } + if (t === 'text') { + return ( + <> +
+ + setForm((f) => ({ ...f, vr_max_length: e.target.value }))} + /> +
+
+ +
+ + ) + } + if (t === 'enum') { + return ( +
+ +