Merge pull request 'Indvidual Dashboard V0.9' (#67) from develop into main
Reviewed-on: #67
This commit is contained in:
commit
09439ad1a5
|
|
@ -7,6 +7,7 @@
|
||||||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.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
|
## Claude Code Verantwortlichkeiten
|
||||||
|
|
||||||
|
|
@ -17,6 +18,7 @@
|
||||||
- ✅ Bestehende Issues aktualisieren (Status, Beschreibung)
|
- ✅ Bestehende Issues aktualisieren (Status, Beschreibung)
|
||||||
- ✅ Issues bei Fertigstellung schließen
|
- ✅ Issues bei Fertigstellung schließen
|
||||||
- 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
- 🎯 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:**
|
**Dokumentation:**
|
||||||
- Code-Änderungen in CLAUDE.md dokumentieren
|
- Code-Änderungen in CLAUDE.md dokumentieren
|
||||||
|
|
@ -841,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
||||||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
|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
|
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||||
> Änderungen aktualisiert.
|
> Änderungen aktualisiert.
|
||||||
|
|
|
||||||
127
backend/dashboard_layout_schema.py
Normal file
127
backend/dashboard_layout_schema.py
Normal file
|
|
@ -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()
|
||||||
165
backend/dashboard_widget_config.py
Normal file
165
backend/dashboard_widget_config.py
Normal file
|
|
@ -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}
|
||||||
|
|
||||||
|
|
||||||
87
backend/dashboard_widget_entitlements.py
Normal file
87
backend/dashboard_widget_entitlements.py
Normal file
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -19,6 +19,10 @@ Modules:
|
||||||
- goals: Active goals, progress, projections
|
- goals: Active goals, progress, projections
|
||||||
- correlations: Lag-analysis, plateau detection
|
- correlations: Lag-analysis, plateau detection
|
||||||
- utils: Shared functions (confidence, baseline, outliers)
|
- utils: Shared functions (confidence, baseline, outliers)
|
||||||
|
- training_profile: Template-based training evaluation scaffold (Layer 1)
|
||||||
|
Import: ``from data_layer.training_profile import resolve_training_evaluation``
|
||||||
|
- reference_values: Profile reference value reads + summary (Layer 1)
|
||||||
|
Import: ``from data_layer import reference_values`` or submodule imports
|
||||||
|
|
||||||
Phase 0c: Multi-Layer Architecture
|
Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
|
|
|
||||||
179
backend/data_layer/reference_values.py
Normal file
179
backend/data_layer/reference_values.py
Normal file
|
|
@ -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}
|
||||||
36
backend/data_layer/training_profile/__init__.py
Normal file
36
backend/data_layer/training_profile/__init__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""
|
||||||
|
Training profile resolver (Layer 1 scaffold).
|
||||||
|
|
||||||
|
Template-driven multi-dimensional evaluation with built-in algorithms and
|
||||||
|
Focus Area contribution aggregation. Import explicitly from this package.
|
||||||
|
|
||||||
|
Public API:
|
||||||
|
- resolve_training_evaluation
|
||||||
|
- resolve_for_base_profile
|
||||||
|
- models: CalculationTemplate, TrainingEvaluationResult, ...
|
||||||
|
- registries: templates, profiles, algorithms
|
||||||
|
"""
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import (
|
||||||
|
CalculationTemplate,
|
||||||
|
DimensionResult,
|
||||||
|
DimensionSpec,
|
||||||
|
FocusAreaMapping,
|
||||||
|
TrainingBaseProfile,
|
||||||
|
TrainingEvaluationResult,
|
||||||
|
)
|
||||||
|
from data_layer.training_profile.resolver import (
|
||||||
|
resolve_for_base_profile,
|
||||||
|
resolve_training_evaluation,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CalculationTemplate",
|
||||||
|
"DimensionResult",
|
||||||
|
"DimensionSpec",
|
||||||
|
"FocusAreaMapping",
|
||||||
|
"TrainingBaseProfile",
|
||||||
|
"TrainingEvaluationResult",
|
||||||
|
"resolve_for_base_profile",
|
||||||
|
"resolve_training_evaluation",
|
||||||
|
]
|
||||||
13
backend/data_layer/training_profile/algorithms/__init__.py
Normal file
13
backend/data_layer/training_profile/algorithms/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Built-in training evaluation algorithms (code-defined only)."""
|
||||||
|
|
||||||
|
from .registry import (
|
||||||
|
get_algorithm,
|
||||||
|
list_algorithm_ids,
|
||||||
|
register_algorithm,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_algorithm",
|
||||||
|
"list_algorithm_ids",
|
||||||
|
"register_algorithm",
|
||||||
|
]
|
||||||
23
backend/data_layer/training_profile/algorithms/base.py
Normal file
23
backend/data_layer/training_profile/algorithms/base.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""
|
||||||
|
Algorithm protocol: fixed implementations selected by id from declarative templates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Mapping, Protocol
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import AlgorithmRunResult
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingAlgorithm(Protocol):
|
||||||
|
"""Built-in algorithm callable shape."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
inputs: Mapping[str, Any],
|
||||||
|
params: Mapping[str, Any],
|
||||||
|
) -> AlgorithmRunResult:
|
||||||
|
...
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
"""Example built-in algorithms (threshold bands, linear range mapping)."""
|
||||||
|
|
||||||
|
from .linear_range import linear_range_score
|
||||||
|
from .threshold_band import threshold_band_score
|
||||||
|
|
||||||
|
__all__ = ["linear_range_score", "threshold_band_score"]
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
"""
|
||||||
|
linear_range: map a value linearly from [min_value, max_value] to [0, 1], clamped.
|
||||||
|
|
||||||
|
params:
|
||||||
|
value_key: str
|
||||||
|
min_value: float
|
||||||
|
max_value: float
|
||||||
|
invert: bool (optional) — if True, high values map to 0
|
||||||
|
|
||||||
|
Missing value → score 0 and missing_inputs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import AlgorithmRunResult
|
||||||
|
from data_layer.utils import safe_float
|
||||||
|
|
||||||
|
|
||||||
|
ALGORITHM_ID = "linear_range"
|
||||||
|
|
||||||
|
|
||||||
|
def linear_range_score(
|
||||||
|
*,
|
||||||
|
inputs: Mapping[str, Any],
|
||||||
|
params: Mapping[str, Any],
|
||||||
|
) -> AlgorithmRunResult:
|
||||||
|
value_key = str(params.get("value_key", ""))
|
||||||
|
if not value_key:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=0.0,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=["__param_value_key__"],
|
||||||
|
detail={"error": "params.value_key required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if value_key not in inputs or inputs[value_key] is None:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=0.0,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=[value_key],
|
||||||
|
detail={"value_key": value_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = safe_float(inputs[value_key])
|
||||||
|
lo = safe_float(params.get("min_value"))
|
||||||
|
hi = safe_float(params.get("max_value"))
|
||||||
|
invert = bool(params.get("invert", False))
|
||||||
|
|
||||||
|
if hi <= lo:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=raw,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=[],
|
||||||
|
detail={"error": "max_value must be > min_value", "min": lo, "max": hi},
|
||||||
|
)
|
||||||
|
|
||||||
|
t = (raw - lo) / (hi - lo)
|
||||||
|
t = max(0.0, min(1.0, t))
|
||||||
|
if invert:
|
||||||
|
t = 1.0 - t
|
||||||
|
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=raw,
|
||||||
|
normalized_score=t,
|
||||||
|
missing_inputs=[],
|
||||||
|
detail={"value_key": value_key, "min": lo, "max": hi, "invert": invert},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""
|
||||||
|
threshold_band: map a numeric value into a score using ordered upper bounds.
|
||||||
|
|
||||||
|
params:
|
||||||
|
value_key: str — key in inputs to read
|
||||||
|
bands: list of { "max": float | null, "score": float } — ascending max; null = +inf
|
||||||
|
|
||||||
|
Missing value_key → normalized_score 0, missing_inputs lists value_key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, List, Mapping
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import AlgorithmRunResult
|
||||||
|
from data_layer.utils import safe_float
|
||||||
|
|
||||||
|
|
||||||
|
ALGORITHM_ID = "threshold_band"
|
||||||
|
|
||||||
|
|
||||||
|
def threshold_band_score(
|
||||||
|
*,
|
||||||
|
inputs: Mapping[str, Any],
|
||||||
|
params: Mapping[str, Any],
|
||||||
|
) -> AlgorithmRunResult:
|
||||||
|
value_key = str(params.get("value_key", ""))
|
||||||
|
if not value_key:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=0.0,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=["__param_value_key__"],
|
||||||
|
detail={"error": "params.value_key required"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if value_key not in inputs or inputs[value_key] is None:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=0.0,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=[value_key],
|
||||||
|
detail={"value_key": value_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = safe_float(inputs[value_key])
|
||||||
|
bands = params.get("bands") or []
|
||||||
|
if not isinstance(bands, list) or not bands:
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=raw,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=[],
|
||||||
|
detail={"error": "params.bands must be a non-empty list", "raw": raw},
|
||||||
|
)
|
||||||
|
|
||||||
|
score = 0.0
|
||||||
|
for band in bands:
|
||||||
|
if not isinstance(band, dict):
|
||||||
|
continue
|
||||||
|
max_v = band.get("max")
|
||||||
|
s = safe_float(band.get("score"))
|
||||||
|
if max_v is None:
|
||||||
|
score = s
|
||||||
|
break
|
||||||
|
if raw <= safe_float(max_v):
|
||||||
|
score = s
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
score = safe_float(bands[-1].get("score")) if isinstance(bands[-1], dict) else 0.0
|
||||||
|
|
||||||
|
norm = max(0.0, min(1.0, score))
|
||||||
|
return AlgorithmRunResult(
|
||||||
|
raw_score=raw,
|
||||||
|
normalized_score=norm,
|
||||||
|
missing_inputs=[],
|
||||||
|
detail={"value_key": value_key, "bands_applied": True, "band_score": score},
|
||||||
|
)
|
||||||
47
backend/data_layer/training_profile/algorithms/registry.py
Normal file
47
backend/data_layer/training_profile/algorithms/registry.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
"""
|
||||||
|
Registry for built-in algorithm ids → callables.
|
||||||
|
|
||||||
|
Only code registers algorithms; templates reference ids declared here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Mapping
|
||||||
|
|
||||||
|
from data_layer.training_profile.algorithms.builtin.linear_range import (
|
||||||
|
ALGORITHM_ID as LINEAR_RANGE_ID,
|
||||||
|
linear_range_score,
|
||||||
|
)
|
||||||
|
from data_layer.training_profile.algorithms.builtin.threshold_band import (
|
||||||
|
ALGORITHM_ID as THRESHOLD_BAND_ID,
|
||||||
|
threshold_band_score,
|
||||||
|
)
|
||||||
|
from data_layer.training_profile.models import AlgorithmRunResult
|
||||||
|
|
||||||
|
AlgorithmFn = Callable[..., AlgorithmRunResult]
|
||||||
|
|
||||||
|
_REGISTRY: Dict[str, AlgorithmFn] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_algorithm(algorithm_id: str, fn: AlgorithmFn) -> None:
|
||||||
|
if algorithm_id in _REGISTRY:
|
||||||
|
raise ValueError(f"Algorithm already registered: {algorithm_id}")
|
||||||
|
_REGISTRY[algorithm_id] = fn
|
||||||
|
|
||||||
|
|
||||||
|
def get_algorithm(algorithm_id: str) -> AlgorithmFn:
|
||||||
|
if algorithm_id not in _REGISTRY:
|
||||||
|
raise KeyError(f"Unknown algorithm_id: {algorithm_id}")
|
||||||
|
return _REGISTRY[algorithm_id]
|
||||||
|
|
||||||
|
|
||||||
|
def list_algorithm_ids() -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(_REGISTRY.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
def _register_defaults() -> None:
|
||||||
|
register_algorithm(THRESHOLD_BAND_ID, threshold_band_score)
|
||||||
|
register_algorithm(LINEAR_RANGE_ID, linear_range_score)
|
||||||
|
|
||||||
|
|
||||||
|
_register_defaults()
|
||||||
121
backend/data_layer/training_profile/models.py
Normal file
121
backend/data_layer/training_profile/models.py
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
"""
|
||||||
|
Declarative schemas for template-based training evaluation (Layer 1 scaffold).
|
||||||
|
|
||||||
|
Templates select built-in algorithms by id and pass parameters; they do not embed
|
||||||
|
executable logic. See resolver and algorithm registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FocusAreaMapping:
|
||||||
|
"""Maps a share of one dimension's score to a Focus Area (by stable key)."""
|
||||||
|
|
||||||
|
focus_area_key: str
|
||||||
|
weight: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DimensionSpec:
|
||||||
|
"""
|
||||||
|
One evaluation dimension: algorithm + inputs + params + FA mapping.
|
||||||
|
|
||||||
|
inputs: names of keys expected in the flat activity_inputs dict passed to the resolver.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
algorithm_id: str
|
||||||
|
inputs: tuple[str, ...]
|
||||||
|
params: Mapping[str, Any]
|
||||||
|
maps_to: tuple[FocusAreaMapping, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalculationTemplate:
|
||||||
|
"""Declarative multi-dimensional calculation template (in-code registry)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
version: str
|
||||||
|
label: str
|
||||||
|
dimensions: tuple[DimensionSpec, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrainingBaseProfile:
|
||||||
|
"""
|
||||||
|
Conceptual base profile: links a training context to a default template.
|
||||||
|
|
||||||
|
allowed_dimension_keys: if set, dimensions not listed are skipped at resolve time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
default_template_id: str
|
||||||
|
allowed_dimension_keys: Optional[frozenset[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlgorithmRunResult:
|
||||||
|
"""Output of a single built-in algorithm execution."""
|
||||||
|
|
||||||
|
raw_score: float
|
||||||
|
normalized_score: float
|
||||||
|
missing_inputs: List[str]
|
||||||
|
detail: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DimensionResult:
|
||||||
|
"""Per-dimension result after resolution."""
|
||||||
|
|
||||||
|
dimension_key: str
|
||||||
|
algorithm_id: str
|
||||||
|
raw_score: float
|
||||||
|
normalized_score: float
|
||||||
|
missing_inputs: List[str]
|
||||||
|
evidence: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainingEvaluationResult:
|
||||||
|
"""
|
||||||
|
Stable structured result for Layer 2 (KI, charts, APIs, future persistence).
|
||||||
|
|
||||||
|
focus_area_contributions: aggregated contribution per focus_area key (additive).
|
||||||
|
"""
|
||||||
|
|
||||||
|
template_id: str
|
||||||
|
template_version: str
|
||||||
|
base_profile_key: Optional[str]
|
||||||
|
dimension_results: List[DimensionResult]
|
||||||
|
focus_area_contributions: Dict[str, float]
|
||||||
|
confidence: str
|
||||||
|
evidence: Dict[str, Any]
|
||||||
|
trace: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def to_serializable(self) -> Dict[str, Any]:
|
||||||
|
"""JSON-compatible dict (for APIs / storage)."""
|
||||||
|
return {
|
||||||
|
"template_id": self.template_id,
|
||||||
|
"template_version": self.template_version,
|
||||||
|
"base_profile_key": self.base_profile_key,
|
||||||
|
"dimension_results": [
|
||||||
|
{
|
||||||
|
"dimension_key": d.dimension_key,
|
||||||
|
"algorithm_id": d.algorithm_id,
|
||||||
|
"raw_score": d.raw_score,
|
||||||
|
"normalized_score": d.normalized_score,
|
||||||
|
"missing_inputs": d.missing_inputs,
|
||||||
|
"evidence": d.evidence,
|
||||||
|
}
|
||||||
|
for d in self.dimension_results
|
||||||
|
],
|
||||||
|
"focus_area_contributions": dict(self.focus_area_contributions),
|
||||||
|
"confidence": self.confidence,
|
||||||
|
"evidence": self.evidence,
|
||||||
|
"trace": self.trace,
|
||||||
|
}
|
||||||
13
backend/data_layer/training_profile/profiles/__init__.py
Normal file
13
backend/data_layer/training_profile/profiles/__init__.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
"""Training base profile registrations (scaffold, in-code)."""
|
||||||
|
|
||||||
|
from .registry import (
|
||||||
|
get_training_base_profile,
|
||||||
|
list_training_base_profile_keys,
|
||||||
|
try_get_training_base_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_training_base_profile",
|
||||||
|
"list_training_base_profile_keys",
|
||||||
|
"try_get_training_base_profile",
|
||||||
|
]
|
||||||
52
backend/data_layer/training_profile/profiles/registry.py
Normal file
52
backend/data_layer/training_profile/profiles/registry.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""
|
||||||
|
Example training base profiles — point to default templates and optional dimension filters.
|
||||||
|
|
||||||
|
Future: DB-backed training types may reference profile keys; this registry is code-only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import TrainingBaseProfile
|
||||||
|
|
||||||
|
_PROFILES: Dict[str, TrainingBaseProfile] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _register(p: TrainingBaseProfile) -> None:
|
||||||
|
if p.key in _PROFILES:
|
||||||
|
raise ValueError(f"Duplicate training base profile key: {p.key}")
|
||||||
|
_PROFILES[p.key] = p
|
||||||
|
|
||||||
|
|
||||||
|
def get_training_base_profile(key: str) -> TrainingBaseProfile:
|
||||||
|
if key not in _PROFILES:
|
||||||
|
raise KeyError(f"Unknown training base profile: {key}")
|
||||||
|
return _PROFILES[key]
|
||||||
|
|
||||||
|
|
||||||
|
def try_get_training_base_profile(key: str) -> Optional[TrainingBaseProfile]:
|
||||||
|
return _PROFILES.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def list_training_base_profile_keys() -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(_PROFILES.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
_register(
|
||||||
|
TrainingBaseProfile(
|
||||||
|
key="scaffold_aerobic_base",
|
||||||
|
label="Scaffold: aerobic base profile",
|
||||||
|
default_template_id="scaffold_example_aerobic_v1",
|
||||||
|
allowed_dimension_keys=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_register(
|
||||||
|
TrainingBaseProfile(
|
||||||
|
key="scaffold_strength_base",
|
||||||
|
label="Scaffold: strength base profile",
|
||||||
|
default_template_id="scaffold_example_strength_v1",
|
||||||
|
allowed_dimension_keys=frozenset({"effort"}),
|
||||||
|
)
|
||||||
|
)
|
||||||
160
backend/data_layer/training_profile/resolver.py
Normal file
160
backend/data_layer/training_profile/resolver.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""
|
||||||
|
Layer 1 entry: resolve a multi-dimensional training evaluation from a template.
|
||||||
|
|
||||||
|
Pure calculation orchestration — no DB, no HTTP, no formatting for KI/charts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Dict, List, Mapping, Optional
|
||||||
|
|
||||||
|
from data_layer.training_profile.algorithms.registry import get_algorithm
|
||||||
|
from data_layer.training_profile.models import (
|
||||||
|
CalculationTemplate,
|
||||||
|
DimensionResult,
|
||||||
|
DimensionSpec,
|
||||||
|
TrainingBaseProfile,
|
||||||
|
TrainingEvaluationResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_inputs_present(
|
||||||
|
activity_inputs: Mapping[str, Any], keys: tuple[str, ...]
|
||||||
|
) -> tuple[bool, List[str]]:
|
||||||
|
missing: List[str] = []
|
||||||
|
for k in keys:
|
||||||
|
if k not in activity_inputs or activity_inputs[k] is None:
|
||||||
|
missing.append(k)
|
||||||
|
return (len(missing) == 0, missing)
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_level(total_dims: int, dims_with_any_missing: int) -> str:
|
||||||
|
if total_dims == 0:
|
||||||
|
return "insufficient"
|
||||||
|
if dims_with_any_missing == 0:
|
||||||
|
return "high"
|
||||||
|
if dims_with_any_missing >= total_dims:
|
||||||
|
return "insufficient"
|
||||||
|
if dims_with_any_missing == 1:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_dimensions(
|
||||||
|
template: CalculationTemplate, base_profile: Optional[TrainingBaseProfile]
|
||||||
|
) -> tuple[DimensionSpec, ...]:
|
||||||
|
if base_profile is None or base_profile.allowed_dimension_keys is None:
|
||||||
|
return template.dimensions
|
||||||
|
allowed = base_profile.allowed_dimension_keys
|
||||||
|
return tuple(d for d in template.dimensions if d.key in allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_training_evaluation(
|
||||||
|
*,
|
||||||
|
activity_inputs: Mapping[str, Any],
|
||||||
|
template: CalculationTemplate,
|
||||||
|
base_profile: Optional[TrainingBaseProfile] = None,
|
||||||
|
include_trace: bool = False,
|
||||||
|
) -> TrainingEvaluationResult:
|
||||||
|
"""
|
||||||
|
Run all template dimensions, aggregate Focus Area contributions, attach evidence.
|
||||||
|
|
||||||
|
activity_inputs: flat dict (e.g. avg_hr, duration_min, distance_km) supplied by caller.
|
||||||
|
"""
|
||||||
|
dimensions = _filter_dimensions(template, base_profile)
|
||||||
|
dimension_results: List[DimensionResult] = []
|
||||||
|
contributions: Dict[str, float] = defaultdict(float)
|
||||||
|
evidence: Dict[str, Any] = {
|
||||||
|
"dimensions_total": len(dimensions),
|
||||||
|
"inputs_keys": sorted(activity_inputs.keys()),
|
||||||
|
}
|
||||||
|
trace: Optional[Dict[str, Any]] = {} if include_trace else None
|
||||||
|
|
||||||
|
dims_with_missing = 0
|
||||||
|
|
||||||
|
for spec in dimensions:
|
||||||
|
ok, missing = _required_inputs_present(activity_inputs, spec.inputs)
|
||||||
|
if not ok:
|
||||||
|
dims_with_missing += 1
|
||||||
|
dimension_results.append(
|
||||||
|
DimensionResult(
|
||||||
|
dimension_key=spec.key,
|
||||||
|
algorithm_id=spec.algorithm_id,
|
||||||
|
raw_score=0.0,
|
||||||
|
normalized_score=0.0,
|
||||||
|
missing_inputs=list(missing),
|
||||||
|
evidence={"skipped": True, "reason": "required_inputs_missing"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if trace is not None:
|
||||||
|
trace[spec.key] = {"skipped": True, "missing": missing}
|
||||||
|
continue
|
||||||
|
|
||||||
|
algo = get_algorithm(spec.algorithm_id)
|
||||||
|
slice_inputs = {k: activity_inputs[k] for k in spec.inputs}
|
||||||
|
run = algo(inputs=slice_inputs, params=dict(spec.params))
|
||||||
|
|
||||||
|
if run.missing_inputs:
|
||||||
|
dims_with_missing += 1
|
||||||
|
|
||||||
|
dimension_results.append(
|
||||||
|
DimensionResult(
|
||||||
|
dimension_key=spec.key,
|
||||||
|
algorithm_id=spec.algorithm_id,
|
||||||
|
raw_score=run.raw_score,
|
||||||
|
normalized_score=run.normalized_score,
|
||||||
|
missing_inputs=list(run.missing_inputs),
|
||||||
|
evidence={"algorithm_detail": run.detail},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in spec.maps_to:
|
||||||
|
contributions[m.focus_area_key] += run.normalized_score * m.weight
|
||||||
|
|
||||||
|
if trace is not None:
|
||||||
|
trace[spec.key] = {
|
||||||
|
"inputs": dict(slice_inputs),
|
||||||
|
"params": dict(spec.params),
|
||||||
|
"run": {
|
||||||
|
"raw_score": run.raw_score,
|
||||||
|
"normalized_score": run.normalized_score,
|
||||||
|
"missing_inputs": run.missing_inputs,
|
||||||
|
"detail": run.detail,
|
||||||
|
},
|
||||||
|
"maps_to": [(x.focus_area_key, x.weight) for x in spec.maps_to],
|
||||||
|
}
|
||||||
|
|
||||||
|
conf = _confidence_level(len(dimensions), dims_with_missing)
|
||||||
|
evidence["dimensions_with_missing_or_failed"] = dims_with_missing
|
||||||
|
|
||||||
|
return TrainingEvaluationResult(
|
||||||
|
template_id=template.id,
|
||||||
|
template_version=template.version,
|
||||||
|
base_profile_key=base_profile.key if base_profile else None,
|
||||||
|
dimension_results=dimension_results,
|
||||||
|
focus_area_contributions=dict(contributions),
|
||||||
|
confidence=conf,
|
||||||
|
evidence=evidence,
|
||||||
|
trace=trace,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_for_base_profile(
|
||||||
|
*,
|
||||||
|
activity_inputs: Mapping[str, Any],
|
||||||
|
base_profile_key: str,
|
||||||
|
include_trace: bool = False,
|
||||||
|
) -> TrainingEvaluationResult:
|
||||||
|
"""Convenience: load profile + default template from registries."""
|
||||||
|
from data_layer.training_profile.profiles.registry import get_training_base_profile
|
||||||
|
from data_layer.training_profile.templates.registry import get_calculation_template
|
||||||
|
|
||||||
|
profile = get_training_base_profile(base_profile_key)
|
||||||
|
template = get_calculation_template(profile.default_template_id)
|
||||||
|
return resolve_training_evaluation(
|
||||||
|
activity_inputs=activity_inputs,
|
||||||
|
template=template,
|
||||||
|
base_profile=profile,
|
||||||
|
include_trace=include_trace,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
"""Declarative calculation templates (in-code registry, scaffold)."""
|
||||||
|
|
||||||
|
from .registry import get_calculation_template, list_calculation_template_ids
|
||||||
|
|
||||||
|
__all__ = ["get_calculation_template", "list_calculation_template_ids"]
|
||||||
96
backend/data_layer/training_profile/templates/registry.py
Normal file
96
backend/data_layer/training_profile/templates/registry.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""
|
||||||
|
Example calculation templates — declarative only; algorithms are referenced by id.
|
||||||
|
|
||||||
|
These are scaffolding examples, not production coaching rules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from data_layer.training_profile.models import CalculationTemplate, DimensionSpec, FocusAreaMapping
|
||||||
|
|
||||||
|
_TEMPLATES: Dict[str, CalculationTemplate] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _register(t: CalculationTemplate) -> None:
|
||||||
|
if t.id in _TEMPLATES:
|
||||||
|
raise ValueError(f"Duplicate template id: {t.id}")
|
||||||
|
_TEMPLATES[t.id] = t
|
||||||
|
|
||||||
|
|
||||||
|
def get_calculation_template(template_id: str) -> CalculationTemplate:
|
||||||
|
if template_id not in _TEMPLATES:
|
||||||
|
raise KeyError(f"Unknown calculation template: {template_id}")
|
||||||
|
return _TEMPLATES[template_id]
|
||||||
|
|
||||||
|
|
||||||
|
def list_calculation_template_ids() -> tuple[str, ...]:
|
||||||
|
return tuple(sorted(_TEMPLATES.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
# --- Example templates (illustrative) ---
|
||||||
|
|
||||||
|
_example_aerobic = CalculationTemplate(
|
||||||
|
id="scaffold_example_aerobic_v1",
|
||||||
|
version="1",
|
||||||
|
label="Scaffold: aerobic-style intensity + volume (example)",
|
||||||
|
dimensions=(
|
||||||
|
DimensionSpec(
|
||||||
|
key="intensity",
|
||||||
|
algorithm_id="threshold_band",
|
||||||
|
inputs=("avg_hr", "duration_min"),
|
||||||
|
params={
|
||||||
|
"value_key": "avg_hr",
|
||||||
|
"bands": [
|
||||||
|
{"max": 120, "score": 0.25},
|
||||||
|
{"max": 140, "score": 0.55},
|
||||||
|
{"max": 160, "score": 0.85},
|
||||||
|
{"max": None, "score": 1.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
maps_to=(
|
||||||
|
FocusAreaMapping("aerobic_endurance", 0.7),
|
||||||
|
FocusAreaMapping("cardiovascular_health", 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DimensionSpec(
|
||||||
|
key="volume",
|
||||||
|
algorithm_id="linear_range",
|
||||||
|
inputs=("duration_min", "distance_km"),
|
||||||
|
params={
|
||||||
|
"value_key": "duration_min",
|
||||||
|
"min_value": 10.0,
|
||||||
|
"max_value": 90.0,
|
||||||
|
"invert": False,
|
||||||
|
},
|
||||||
|
maps_to=(FocusAreaMapping("aerobic_endurance", 0.8),),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_example_strength = CalculationTemplate(
|
||||||
|
id="scaffold_example_strength_v1",
|
||||||
|
version="1",
|
||||||
|
label="Scaffold: strength session load (example)",
|
||||||
|
dimensions=(
|
||||||
|
DimensionSpec(
|
||||||
|
key="effort",
|
||||||
|
algorithm_id="linear_range",
|
||||||
|
inputs=("duration_min",),
|
||||||
|
params={
|
||||||
|
"value_key": "duration_min",
|
||||||
|
"min_value": 20.0,
|
||||||
|
"max_value": 75.0,
|
||||||
|
"invert": False,
|
||||||
|
},
|
||||||
|
maps_to=(
|
||||||
|
FocusAreaMapping("strength", 0.9),
|
||||||
|
FocusAreaMapping("strength_endurance", 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
_register(_example_aerobic)
|
||||||
|
_register(_example_strength)
|
||||||
20
backend/focus_area_usage_helpers.py
Normal file
20
backend/focus_area_usage_helpers.py
Normal file
|
|
@ -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 []
|
||||||
|
|
@ -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 charts # Phase 0c Multi-Layer Architecture
|
||||||
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
from routers import workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
||||||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||||
|
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||||
|
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||||
|
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||||
|
|
||||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
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
|
# Phase 1-2 Workflow Engine
|
||||||
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
|
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
|
||||||
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
||||||
|
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||||
|
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 ──────────────────────────────────────────────────────────────
|
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
|
||||||
36
backend/migrations/036_focus_area_usage_types.sql
Normal file
36
backend/migrations/036_focus_area_usage_types.sql
Normal file
|
|
@ -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;
|
||||||
99
backend/migrations/037_profile_reference_values.sql
Normal file
99
backend/migrations/037_profile_reference_values.sql
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
-- Migration 037: Persönliche Referenzwerte (Typkatalog + historische Werte pro Profil)
|
||||||
|
-- Date: 2026-04-06
|
||||||
|
-- Purpose: System-definierte Referenztyp-Schlüssel; Nutzer pflegt nur historische Einträge.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reference_value_types (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
key VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
label VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
default_unit VARCHAR(32),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE reference_value_types IS
|
||||||
|
'Systemdefinierte Typen persönlicher Referenzwerte (kein Nutzer-CRUD auf Typen)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS profile_reference_values (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
reference_value_type_id INT NOT NULL REFERENCES reference_value_types(id) ON DELETE RESTRICT,
|
||||||
|
effective_date DATE NOT NULL,
|
||||||
|
value_numeric NUMERIC(18, 6),
|
||||||
|
value_text TEXT,
|
||||||
|
unit VARCHAR(32) NOT NULL,
|
||||||
|
source TEXT,
|
||||||
|
confidence NUMERIC(5, 2),
|
||||||
|
method TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
extra JSONB NOT NULL DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT profile_reference_values_value_ck CHECK (
|
||||||
|
value_numeric IS NOT NULL OR (value_text IS NOT NULL AND length(trim(value_text)) > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE profile_reference_values IS
|
||||||
|
'Historische Referenzwerte pro Profil (kein Überschreiben eines Einzel-»aktuellen« Werts)';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prv_profile_type_date
|
||||||
|
ON profile_reference_values (profile_id, reference_value_type_id, effective_date DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_prv_profile
|
||||||
|
ON profile_reference_values (profile_id);
|
||||||
|
|
||||||
|
-- Seed: nur Typdefinitionen, keine Benutzerwerte
|
||||||
|
INSERT INTO reference_value_types (key, label, description, default_unit, sort_order, active) VALUES
|
||||||
|
(
|
||||||
|
'max_heart_rate',
|
||||||
|
'Maximale Herzfrequenz',
|
||||||
|
'Individuelle HRmax (z. B. aus Leistungstest oder geschätzt).',
|
||||||
|
'bpm',
|
||||||
|
10,
|
||||||
|
TRUE
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'anaerobic_threshold_hr',
|
||||||
|
'Anaerober Schwellenwert (Herzfrequenz)',
|
||||||
|
'Laktatschwelle / anaerober Schwellenpuls.',
|
||||||
|
'bpm',
|
||||||
|
20,
|
||||||
|
TRUE
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'aerobic_threshold_hr',
|
||||||
|
'Aerober Schwellenwert (Herzfrequenz)',
|
||||||
|
'Erster aerobet/schwellenanaloger Trainingsbereich (GA2).',
|
||||||
|
'bpm',
|
||||||
|
30,
|
||||||
|
TRUE
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'training_frequency_weekly',
|
||||||
|
'Trainingshäufigkeit',
|
||||||
|
'Geplante oder beobachtete Einheiten pro Woche.',
|
||||||
|
'Sessions/Woche',
|
||||||
|
40,
|
||||||
|
TRUE
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'fitness_level',
|
||||||
|
'Fitnesslevel',
|
||||||
|
'Subjektive oder normierte Einstufung (Zahl oder Kurzbeschreibung im Freitextfeld).',
|
||||||
|
'Stufe',
|
||||||
|
50,
|
||||||
|
TRUE
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'resting_heart_rate',
|
||||||
|
'Ruhepuls (Referenz)',
|
||||||
|
'Ruheherzfrequenz als persönliche Referenz (z. B. morgens).',
|
||||||
|
'bpm',
|
||||||
|
15,
|
||||||
|
TRUE
|
||||||
|
)
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
54
backend/migrations/038_reference_value_type_metadata.sql
Normal file
54
backend/migrations/038_reference_value_type_metadata.sql
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
5
backend/migrations/039_dashboard_layout.sql
Normal file
5
backend/migrations/039_dashboard_layout.sql
Normal file
|
|
@ -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.';
|
||||||
8
backend/migrations/040_system_config.sql
Normal file
8
backend/migrations/040_system_config.sql
Normal file
|
|
@ -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);
|
||||||
170
backend/reference_value_validation.py
Normal file
170
backend/reference_value_validation.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -9,7 +9,7 @@ import uuid
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional
|
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 db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||||
|
|
@ -32,24 +32,45 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
def list_activity(
|
||||||
"""Get activity entries for current profile."""
|
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)
|
pid = get_pid(x_profile_id)
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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,))
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
profile = r2d(cur.fetchone())
|
profile = r2d(cur.fetchone())
|
||||||
quality_filter = get_quality_filter_sql(profile)
|
quality_filter = get_quality_filter_sql(profile)
|
||||||
|
|
||||||
cur.execute(f"""
|
if days is not None:
|
||||||
SELECT * FROM activity_log
|
cur.execute(
|
||||||
WHERE profile_id=%s
|
f"""
|
||||||
{quality_filter}
|
SELECT * FROM activity_log
|
||||||
ORDER BY date DESC, start_time DESC
|
WHERE profile_id=%s
|
||||||
LIMIT %s
|
{quality_filter}
|
||||||
""", (pid, limit))
|
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()]
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,21 @@ import os
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_admin, hash_pin
|
from auth import require_admin, hash_pin
|
||||||
from models import AdminProfileUpdate
|
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"])
|
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"}
|
return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(500, f"Fehler beim Senden: {str(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}
|
||||||
|
|
|
||||||
282
backend/routers/admin_reference_value_types.py
Normal file
282
backend/routers/admin_reference_value_types.py
Normal file
|
|
@ -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}
|
||||||
115
backend/routers/app_dashboard.py
Normal file
115
backend/routers/app_dashboard.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -3,10 +3,11 @@ Focus Areas Router
|
||||||
Manages dynamic focus area definitions and user preferences
|
Manages dynamic focus area definitions and user preferences
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from auth import require_auth
|
from auth import require_auth
|
||||||
|
from focus_area_usage_helpers import coerce_usage_type_keys
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
||||||
|
|
||||||
|
|
@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel):
|
||||||
"""User's focus area weightings (dynamic)"""
|
"""User's focus area weightings (dynamic)"""
|
||||||
preferences: dict # {focus_area_id: weight_pct}
|
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)
|
# Focus Area Definitions (Admin)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -58,14 +64,27 @@ def list_focus_area_definitions(
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
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
|
FROM focus_area_definitions
|
||||||
WHERE is_active = true OR %s
|
WHERE is_active = true OR %s
|
||||||
ORDER BY category, name_de
|
ORDER BY category, name_de
|
||||||
"""
|
"""
|
||||||
|
|
||||||
cur.execute(query, (include_inactive,))
|
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
|
# Group by category
|
||||||
grouped = {}
|
grouped = {}
|
||||||
|
|
@ -75,6 +94,10 @@ def list_focus_area_definitions(
|
||||||
grouped[cat] = []
|
grouped[cat] = []
|
||||||
grouped[cat].append(area)
|
grouped[cat].append(area)
|
||||||
|
|
||||||
|
if session.get('role') != 'admin':
|
||||||
|
for area in areas:
|
||||||
|
area.pop('allowed_usage_type_keys', None)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"areas": areas,
|
"areas": areas,
|
||||||
"grouped": grouped,
|
"grouped": grouped,
|
||||||
|
|
@ -226,6 +249,92 @@ def delete_focus_area_definition(
|
||||||
|
|
||||||
return {"message": "Focus Area gelöscht"}
|
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
|
# User Focus Preferences
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
320
backend/routers/reference_values.py
Normal file
320
backend/routers/reference_values.py
Normal file
|
|
@ -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}
|
||||||
77
backend/system_dashboard_product_default.py
Normal file
77
backend/system_dashboard_product_default.py
Normal file
|
|
@ -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,),
|
||||||
|
)
|
||||||
58
backend/tests/test_dashboard_layout_schema.py
Normal file
58
backend/tests/test_dashboard_layout_schema.py
Normal file
|
|
@ -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
|
||||||
133
backend/tests/test_dashboard_widget_config.py
Normal file
133
backend/tests/test_dashboard_widget_config.py
Normal file
|
|
@ -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()
|
||||||
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
|
|
@ -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)
|
||||||
127
backend/tests/test_focus_area_usage_types.py
Normal file
127
backend/tests/test_focus_area_usage_types.py
Normal file
|
|
@ -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,))
|
||||||
48
backend/tests/test_reference_values_data_layer.py
Normal file
48
backend/tests/test_reference_values_data_layer.py
Normal file
|
|
@ -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"]
|
||||||
45
backend/tests/test_system_dashboard_product_default.py
Normal file
45
backend/tests/test_system_dashboard_product_default.py
Normal file
|
|
@ -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
|
||||||
138
backend/tests/test_training_profile_resolver.py
Normal file
138
backend/tests/test_training_profile_resolver.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""
|
||||||
|
Unit tests: Layer 1 training profile resolver scaffold.
|
||||||
|
|
||||||
|
No database; pure template + algorithm + resolver behavior.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from data_layer.training_profile import (
|
||||||
|
CalculationTemplate,
|
||||||
|
DimensionSpec,
|
||||||
|
FocusAreaMapping,
|
||||||
|
TrainingEvaluationResult,
|
||||||
|
resolve_for_base_profile,
|
||||||
|
resolve_training_evaluation,
|
||||||
|
)
|
||||||
|
from data_layer.training_profile.algorithms.registry import (
|
||||||
|
get_algorithm,
|
||||||
|
list_algorithm_ids,
|
||||||
|
register_algorithm,
|
||||||
|
)
|
||||||
|
from data_layer.training_profile.models import AlgorithmRunResult
|
||||||
|
from data_layer.training_profile.profiles.registry import get_training_base_profile
|
||||||
|
from data_layer.training_profile.templates.registry import get_calculation_template
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlgorithmRegistry:
|
||||||
|
def test_builtin_algorithms_registered(self):
|
||||||
|
ids = list_algorithm_ids()
|
||||||
|
assert "threshold_band" in ids
|
||||||
|
assert "linear_range" in ids
|
||||||
|
|
||||||
|
def test_get_algorithm_runs_threshold(self):
|
||||||
|
fn = get_algorithm("threshold_band")
|
||||||
|
r = fn(
|
||||||
|
inputs={"avg_hr": 130.0},
|
||||||
|
params={
|
||||||
|
"value_key": "avg_hr",
|
||||||
|
"bands": [
|
||||||
|
{"max": 120, "score": 0.2},
|
||||||
|
{"max": 150, "score": 0.8},
|
||||||
|
{"max": None, "score": 1.0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.normalized_score == 0.8
|
||||||
|
|
||||||
|
def test_duplicate_register_raises(self):
|
||||||
|
def dummy(*, inputs, params):
|
||||||
|
return AlgorithmRunResult(0.0, 0.0, [])
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already registered"):
|
||||||
|
register_algorithm("threshold_band", dummy)
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolver:
|
||||||
|
def test_example_template_resolves(self):
|
||||||
|
tpl = get_calculation_template("scaffold_example_aerobic_v1")
|
||||||
|
result = resolve_training_evaluation(
|
||||||
|
activity_inputs={
|
||||||
|
"avg_hr": 135.0,
|
||||||
|
"duration_min": 45.0,
|
||||||
|
"distance_km": 10.0,
|
||||||
|
},
|
||||||
|
template=tpl,
|
||||||
|
)
|
||||||
|
assert isinstance(result, TrainingEvaluationResult)
|
||||||
|
assert result.template_id == "scaffold_example_aerobic_v1"
|
||||||
|
assert result.confidence == "high"
|
||||||
|
assert "aerobic_endurance" in result.focus_area_contributions
|
||||||
|
assert len(result.dimension_results) == 2
|
||||||
|
for dr in result.dimension_results:
|
||||||
|
assert dr.missing_inputs == []
|
||||||
|
|
||||||
|
def test_missing_required_input_skips_dimension(self):
|
||||||
|
tpl = get_calculation_template("scaffold_example_aerobic_v1")
|
||||||
|
result = resolve_training_evaluation(
|
||||||
|
activity_inputs={"avg_hr": 135.0},
|
||||||
|
template=tpl,
|
||||||
|
)
|
||||||
|
assert result.confidence in ("medium", "low", "insufficient")
|
||||||
|
skipped = [d for d in result.dimension_results if d.evidence.get("skipped")]
|
||||||
|
assert len(skipped) >= 1
|
||||||
|
|
||||||
|
def test_base_profile_filters_dimensions(self):
|
||||||
|
profile = get_training_base_profile("scaffold_strength_base")
|
||||||
|
tpl = get_calculation_template(profile.default_template_id)
|
||||||
|
result = resolve_training_evaluation(
|
||||||
|
activity_inputs={"duration_min": 50.0},
|
||||||
|
template=tpl,
|
||||||
|
base_profile=profile,
|
||||||
|
)
|
||||||
|
assert len(result.dimension_results) == 1
|
||||||
|
assert result.dimension_results[0].dimension_key == "effort"
|
||||||
|
|
||||||
|
def test_resolve_for_base_profile_convenience(self):
|
||||||
|
result = resolve_for_base_profile(
|
||||||
|
activity_inputs={"duration_min": 40.0},
|
||||||
|
base_profile_key="scaffold_strength_base",
|
||||||
|
include_trace=True,
|
||||||
|
)
|
||||||
|
assert result.base_profile_key == "scaffold_strength_base"
|
||||||
|
assert result.trace is not None
|
||||||
|
assert "effort" in result.trace
|
||||||
|
|
||||||
|
def test_to_serializable(self):
|
||||||
|
tpl = get_calculation_template("scaffold_example_strength_v1")
|
||||||
|
r = resolve_training_evaluation(
|
||||||
|
activity_inputs={"duration_min": 45.0},
|
||||||
|
template=tpl,
|
||||||
|
)
|
||||||
|
d = r.to_serializable()
|
||||||
|
assert d["template_id"] == tpl.id
|
||||||
|
assert "focus_area_contributions" in d
|
||||||
|
assert isinstance(d["dimension_results"], list)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomTemplate:
|
||||||
|
def test_unknown_algorithm_raises(self):
|
||||||
|
bad = CalculationTemplate(
|
||||||
|
id="bad",
|
||||||
|
version="1",
|
||||||
|
label="bad",
|
||||||
|
dimensions=(
|
||||||
|
DimensionSpec(
|
||||||
|
key="x",
|
||||||
|
algorithm_id="does_not_exist",
|
||||||
|
inputs=("a",),
|
||||||
|
params={},
|
||||||
|
maps_to=(FocusAreaMapping("strength", 1.0),),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
resolve_training_evaluation(
|
||||||
|
activity_inputs={"a": 1.0},
|
||||||
|
template=bad,
|
||||||
|
)
|
||||||
59
backend/tests/test_widget_catalog.py
Normal file
59
backend/tests/test_widget_catalog.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -9,32 +9,36 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
||||||
|
|
||||||
APP_VERSION = "0.9n"
|
APP_VERSION = "0.9n"
|
||||||
BUILD_DATE = "2026-04-05"
|
BUILD_DATE = "2026-04-05"
|
||||||
DB_SCHEMA_VERSION = "20260403" # Migration 034
|
DB_SCHEMA_VERSION = "20260406d" # Migration 040
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.2.0",
|
"auth": "1.2.0",
|
||||||
"profiles": "1.1.0",
|
"profiles": "1.1.0",
|
||||||
|
"reference_values": "1.3.0",
|
||||||
|
"admin_reference_value_types": "1.0.0",
|
||||||
"weight": "1.0.3",
|
"weight": "1.0.3",
|
||||||
"circumference": "1.0.1",
|
"circumference": "1.0.1",
|
||||||
"caliper": "1.0.1",
|
"caliper": "1.0.1",
|
||||||
"activity": "1.1.0",
|
"activity": "1.2.0", # GET /activity: optional days= window + limit
|
||||||
"nutrition": "1.0.2",
|
"nutrition": "1.0.2",
|
||||||
"photos": "1.0.0",
|
"photos": "1.0.0",
|
||||||
"insights": "1.3.0",
|
"insights": "1.3.0",
|
||||||
"prompts": "1.1.0",
|
"prompts": "1.1.0",
|
||||||
"admin": "1.2.0",
|
"admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full
|
||||||
"stats": "1.0.1",
|
"stats": "1.0.1",
|
||||||
"exportdata": "1.1.0",
|
"exportdata": "1.1.0",
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
"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 = [
|
CHANGELOG = [
|
||||||
{
|
{
|
||||||
"version": "0.9n",
|
"version": "0.9n",
|
||||||
"date": "2026-04-05",
|
"date": "2026-04-06",
|
||||||
"changes": [
|
"changes": [
|
||||||
|
"Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)",
|
||||||
"Phase 4: End Node Template Engine",
|
"Phase 4: End Node Template Engine",
|
||||||
"workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)",
|
"workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)",
|
||||||
"workflow_executor.py: execute_end_node() with Jinja2 template rendering",
|
"workflow_executor.py: execute_end_node() with Jinja2 template rendering",
|
||||||
|
|
|
||||||
153
backend/widget_catalog.py
Normal file
153
backend/widget_catalog.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -22,6 +22,11 @@ import NutritionPage from './pages/NutritionPage'
|
||||||
import ActivityPage from './pages/ActivityPage'
|
import ActivityPage from './pages/ActivityPage'
|
||||||
import Analysis from './pages/Analysis'
|
import Analysis from './pages/Analysis'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import 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 GuidePage from './pages/GuidePage'
|
||||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||||
|
|
@ -34,6 +39,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
||||||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||||
|
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
||||||
import AdminHomePage from './pages/AdminHomePage'
|
import AdminHomePage from './pages/AdminHomePage'
|
||||||
import AdminUsersPage from './pages/AdminUsersPage'
|
import AdminUsersPage from './pages/AdminUsersPage'
|
||||||
import AdminSystemPage from './pages/AdminSystemPage'
|
import AdminSystemPage from './pages/AdminSystemPage'
|
||||||
|
|
@ -224,13 +230,18 @@ function AppShell() {
|
||||||
<Route path="/history" element={<History/>}/>
|
<Route path="/history" element={<History/>}/>
|
||||||
<Route path="/goals" element={<GoalsPage/>}/>
|
<Route path="/goals" element={<GoalsPage/>}/>
|
||||||
<Route path="/analysis" element={<Analysis/>}/>
|
<Route path="/analysis" element={<Analysis/>}/>
|
||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsShell />}>
|
||||||
|
<Route index element={<SettingsPage />} />
|
||||||
|
<Route path="reference-values" element={<ProfileReferenceValuesPage />} />
|
||||||
|
<Route path="dashboard-layout" element={<DashboardConfigurePage />} />
|
||||||
|
</Route>
|
||||||
<Route element={<RequireAdmin />}>
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="admin" element={<AdminShell />}>
|
<Route path="admin" element={<AdminShell />}>
|
||||||
<Route index element={<AdminHomePage />} />
|
<Route index element={<AdminHomePage />} />
|
||||||
<Route path="g/:groupId" element={<AdminGroupHubPage />} />
|
<Route path="g/:groupId" element={<AdminGroupHubPage />} />
|
||||||
<Route path="users" element={<AdminUsersPage />} />
|
<Route path="users" element={<AdminUsersPage />} />
|
||||||
<Route path="system" element={<AdminSystemPage />} />
|
<Route path="system" element={<AdminSystemPage />} />
|
||||||
|
<Route path="dashboard-product-default" element={<DashboardConfigurePage adminMode />} />
|
||||||
<Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
|
<Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||||
<Route path="features" element={<AdminFeaturesPage/>}/>
|
<Route path="features" element={<AdminFeaturesPage/>}/>
|
||||||
<Route path="tiers" element={<AdminTiersPage/>}/>
|
<Route path="tiers" element={<AdminTiersPage/>}/>
|
||||||
|
|
@ -242,10 +253,13 @@ function AppShell() {
|
||||||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||||
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||||
|
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||||
|
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
||||||
|
<Route path="/app/dashboard-lab" element={<DashboardLabPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
|
||||||
.admin-shell {
|
.admin-shell {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
|
||||||
134
frontend/src/components/DashboardStatKit.jsx
Normal file
134
frontend/src/components/DashboardStatKit.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div
|
||||||
|
role={tipText ? 'button' : undefined}
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: color, flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text2)' }}>{label}</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color }}>{value}</span>
|
||||||
|
{sub && <span style={{ fontSize: 10, color: 'var(--text3)' }}>{sub}</span>}
|
||||||
|
{tipText && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--text3)', opacity: 0.7 }} aria-hidden>
|
||||||
|
ⓘ
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{tip && tipText && (
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
onClick={() => 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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong>{label}</strong>
|
||||||
|
<br />
|
||||||
|
{tipText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
className="dashboard-stat-card"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
cursor: onClick ? 'pointer' : 'default',
|
||||||
|
'--tile-sm': String(sm),
|
||||||
|
'--tile-lg': String(lg),
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--accent)')}
|
||||||
|
onMouseLeave={(e) => onClick && (e.currentTarget.style.borderColor = 'var(--border)')}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 18, marginBottom: 4 }}>{icon}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 2 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 19, fontWeight: 700, color: color || 'var(--text1)', lineHeight: 1.1 }}>
|
||||||
|
{value}
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 2 }}>{unit}</span>
|
||||||
|
</div>
|
||||||
|
{delta != null && (
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: deltaColor, marginTop: 2 }}>
|
||||||
|
{delta > 0 ? '+' : ''}
|
||||||
|
{delta} {unit}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sub && <div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>{sub}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
frontend/src/components/QuickWeightEntry.jsx
Normal file
113
frontend/src/components/QuickWeightEntry.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'var(--danger-bg)',
|
||||||
|
border: '1px solid var(--danger)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger)',
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={20}
|
||||||
|
max={300}
|
||||||
|
step={0.1}
|
||||||
|
className="form-input"
|
||||||
|
style={{ flex: 1, fontSize: 17, fontWeight: 600, textAlign: 'center' }}
|
||||||
|
placeholder="kg eingeben"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && !isDisabled && handleSave()}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text3)' }}>kg</span>
|
||||||
|
<div title={tooltipText} style={{ display: 'inline-block' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer' }}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
|
{saved ? (
|
||||||
|
<Check size={15} />
|
||||||
|
) : saving ? (
|
||||||
|
<div className="spinner" style={{ width: 14, height: 14 }} />
|
||||||
|
) : weightUsage && !weightUsage.allowed ? (
|
||||||
|
'🔒 Limit'
|
||||||
|
) : (
|
||||||
|
'Speichern'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,8 +15,10 @@ export default function TrainingTypeDistribution({ days = 28 }) {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
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([
|
Promise.all([
|
||||||
api.listActivity(days),
|
api.listActivity(limit, safeDays),
|
||||||
api.getTrainingCategories()
|
api.getTrainingCategories()
|
||||||
]).then(([activities, cats]) => {
|
]).then(([activities, cats]) => {
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
|
|
@ -43,7 +45,7 @@ export default function TrainingTypeDistribution({ days = 28 }) {
|
||||||
console.error('Failed to load training type distribution:', err)
|
console.error('Failed to load training type distribution:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes
|
}, [days, activeProfile?.quality_filter_level, activeProfile?.id])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
183
frontend/src/components/TrendKcalWeightChart.jsx
Normal file
183
frontend/src/components/TrendKcalWeightChart.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart data={withAvg} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={Math.max(0, Math.floor(withAvg.length / 6) - 1)}
|
||||||
|
/>
|
||||||
|
{hasKcal && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="kcal"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<YAxis
|
||||||
|
yAxisId="weight"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 11,
|
||||||
|
}}
|
||||||
|
formatter={(v, name) => [
|
||||||
|
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 && (
|
||||||
|
<Line
|
||||||
|
yAxisId="kcal"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="kcal"
|
||||||
|
stroke="#EF9F2744"
|
||||||
|
strokeWidth={1}
|
||||||
|
dot={false}
|
||||||
|
connectNulls={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasKcal && (
|
||||||
|
<Line
|
||||||
|
yAxisId="kcal"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="kcal_avg"
|
||||||
|
stroke="#EF9F27"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
name="kcal_avg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<Line
|
||||||
|
yAxisId="weight"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="weightLine"
|
||||||
|
stroke="#378ADD88"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
name="weightLine"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hasW && (
|
||||||
|
<Line
|
||||||
|
yAxisId="weight"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="weight"
|
||||||
|
stroke="#378ADD"
|
||||||
|
strokeWidth={0}
|
||||||
|
dot={(props) => {
|
||||||
|
const { cx, cy, value } = props
|
||||||
|
return value != null ? (
|
||||||
|
<circle
|
||||||
|
key={cx}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r={4}
|
||||||
|
fill="#378ADD"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<g key={cx} />
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
connectNulls={false}
|
||||||
|
name="weight"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>KI-Auswertung</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }} onClick={() => nav('/analysis')}>
|
||||||
|
<Brain size={11} /> Analysen →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary btn-full" style={{ marginBottom: 10 }} onClick={runPipeline} disabled={pipelineLoading}>
|
||||||
|
{pipelineLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="spinner" style={{ width: 13, height: 13 }} /> Analyse läuft… (3 Stufen)
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Brain size={13} /> 🔬 Mehrstufige Analyse starten
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{pipelineError && <div style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{pipelineError}</div>}
|
||||||
|
|
||||||
|
{latestInsight ? (
|
||||||
|
<>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
|
||||||
|
</div>
|
||||||
|
<div style={{ maxHeight: showInsight ? 'none' : 120, overflow: 'hidden', position: 'relative' }}>
|
||||||
|
<Markdown text={latestInsight.content} />
|
||||||
|
{!showInsight && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 40,
|
||||||
|
background: 'linear-gradient(transparent,var(--surface))',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
marginTop: 6,
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={() => setShowInsight((s) => !s)}
|
||||||
|
>
|
||||||
|
{showInsight ? '▲ Weniger anzeigen' : '▼ Vollständig anzeigen'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text3)', padding: '8px 0' }}>
|
||||||
|
Noch keine KI-Auswertung vorhanden.
|
||||||
|
<button type="button" className="btn btn-primary" style={{ marginTop: 8, display: 'block', fontSize: 12 }} onClick={() => nav('/analysis')}>
|
||||||
|
Erste Analyse erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Noch keine Kennzahlen – erfasse Gewicht oder Körperdaten.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Kennzahlen</div>
|
||||||
|
<div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
|
||||||
|
{latestW && (
|
||||||
|
<StatCard
|
||||||
|
icon="⚖️"
|
||||||
|
label="Gewicht"
|
||||||
|
value={latestW.weight}
|
||||||
|
unit="kg"
|
||||||
|
delta={wDelta}
|
||||||
|
deltaGoodWhenNeg
|
||||||
|
sub={dayjs(latestW.date).format('DD.MM.')}
|
||||||
|
onClick={() => nav('/history')}
|
||||||
|
color="#378ADD"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{latestCal?.body_fat_pct != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="🫧"
|
||||||
|
label="Körperfett"
|
||||||
|
value={latestCal.body_fat_pct}
|
||||||
|
unit="%"
|
||||||
|
delta={bfDelta}
|
||||||
|
deltaGoodWhenNeg
|
||||||
|
sub={bfCat?.label}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
color={bfCat?.color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{latestCal?.lean_mass != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="💪"
|
||||||
|
label="Magermasse"
|
||||||
|
value={latestCal.lean_mass}
|
||||||
|
unit="kg"
|
||||||
|
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : '–'}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{avgKcal != null && (
|
||||||
|
<StatCard
|
||||||
|
icon="🍽️"
|
||||||
|
label="Ø Kalorien"
|
||||||
|
value={avgKcal}
|
||||||
|
unit="kcal"
|
||||||
|
sub="letzte 7 Tage"
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
color="#EF9F27"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 22, fontWeight: 800, margin: 0, color: 'var(--text1)' }}>
|
||||||
|
Hallo, {activeProfile?.name || 'Nutzer'} 👋
|
||||||
|
</h2>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>
|
||||||
|
{dayjs().format('dddd, DD. MMMM YYYY')}
|
||||||
|
{latestWeightDate && ` · Letztes Update ${dayjs(latestWeightDate).format('DD.MM.')}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Ziele & Fokus</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/goals')}>
|
||||||
|
Bearbeiten →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/goals')}
|
||||||
|
onClick={() => nav('/goals')}
|
||||||
|
>
|
||||||
|
{goalsCount != null && (
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 8 }}>
|
||||||
|
{goalsCount === 0
|
||||||
|
? 'Noch keine Ziele angelegt.'
|
||||||
|
: `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)' }}>
|
||||||
|
Focus Areas und Fortschritt – tippen zum Öffnen der Ziele-Seite.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Noch keine Ernährungs- oder Aktivitätsdaten (7 Tage).
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryBoth = showNutr && showAct
|
||||||
|
const summarySpanM = summaryBoth ? 1 : 2
|
||||||
|
const summarySpanD = summaryBoth ? 2 : 4
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>
|
||||||
|
Ernährung & Aktivität
|
||||||
|
</div>
|
||||||
|
<div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
||||||
|
{showNutr && (
|
||||||
|
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ cursor: 'pointer', height: '100%' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginBottom: 8, color: 'var(--text3)' }}>
|
||||||
|
🍽️ ERNÄHRUNG (Ø 7T)
|
||||||
|
</div>
|
||||||
|
{avgKcal != null && <div style={{ fontSize: 16, fontWeight: 700, color: '#EF9F27' }}>{avgKcal} kcal</div>}
|
||||||
|
{avgProtein != null && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: proteinOk ? 'var(--accent)' : 'var(--warn)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{avgProtein}g Protein {proteinOk ? '✓' : '⚠️'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>→ Verlauf Ernährung</div>
|
||||||
|
</div>
|
||||||
|
</DashboardTile>
|
||||||
|
)}
|
||||||
|
{showAct && (
|
||||||
|
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{ cursor: 'pointer', height: '100%' }}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && nav('/history', { state: { tab: 'activity' } })}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'activity' } })}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, marginBottom: 8, color: 'var(--text3)' }}>
|
||||||
|
🏋️ AKTIVITÄT (7T)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 16, fontWeight: 700, color: '#EF9F27' }}>{actKcal} kcal</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text2)' }}>{recentAct.length} Trainings</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>→ Verlauf Aktivität</div>
|
||||||
|
</div>
|
||||||
|
</DashboardTile>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Ernährung — Charts</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>API-Charts · {days} Tage</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'nutrition' } })}
|
||||||
|
>
|
||||||
|
Verlauf →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<NutritionCharts key={`${refreshTick}-${days}`} days={days} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Profil-Ziele</div>
|
||||||
|
{gw && latestW && (
|
||||||
|
<div style={{ marginBottom: 10 }}>
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span>
|
||||||
|
Gewicht: {curr} → {goal} kg
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
{remain > 0 ? `noch ${remain}kg` : 'Ziel erreicht! 🎉'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: 'var(--accent)',
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'width 0.5s',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>{pct}% des Weges</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{gbf && latestCal?.body_fat_pct != null && (
|
||||||
|
<div>
|
||||||
|
{(() => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
|
||||||
|
<span>
|
||||||
|
Körperfett: {curr}% → {goal}%
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>
|
||||||
|
{remain > 0 ? `noch ${remain}%` : 'Ziel erreicht! 🎉'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: bfCat?.color || 'var(--accent)',
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2 }}>Aktuell: {bfCat?.label}</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, textAlign: 'center', padding: 24 }}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text3)', marginBottom: 12 }}>Noch keine Fotos.</div>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => nav('/capture')}>
|
||||||
|
Zur Erfassung
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Fortschrittsfotos</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'photos' } })}
|
||||||
|
>
|
||||||
|
Verlauf →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{big && (
|
||||||
|
<div
|
||||||
|
role="presentation"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.9)',
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
onClick={() => setBig(null)}
|
||||||
|
>
|
||||||
|
<img src={api.photoUrl(big)} style={{ maxWidth: '100%', maxHeight: '100%', borderRadius: 8 }} alt="" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="photo-grid">
|
||||||
|
{photos.map((p) => (
|
||||||
|
<div key={p.id} style={{ position: 'relative' }}>
|
||||||
|
<img
|
||||||
|
src={api.photoUrl(p.id)}
|
||||||
|
className="photo-thumb"
|
||||||
|
alt=""
|
||||||
|
onClick={() => setBig(p.id)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
left: 4,
|
||||||
|
fontSize: 9,
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.date?.slice(0, 10) || p.created?.slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import QuickWeightEntry from '../QuickWeightEntry'
|
||||||
|
|
||||||
|
export default function QuickWeightTodayWidget({ onSaved }) {
|
||||||
|
const nav = useNavigate()
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Gewicht heute</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Tageswert erfassen</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/weight')}>
|
||||||
|
Alle Einträge →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<QuickWeightEntry onSaved={onSaved} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Erholung — Charts</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Schlaf, Recovery, Vitalwerte · {days} Tage</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'recovery' } })}
|
||||||
|
>
|
||||||
|
Verlauf →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<RecoveryCharts key={`${refreshTick}-${days}`} days={days} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, marginBottom: 10, color: 'var(--text1)' }}>Erholung</div>
|
||||||
|
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
||||||
|
<DashboardTile spanMobile={1} spanDesktop={2}>
|
||||||
|
<SleepWidget />
|
||||||
|
</DashboardTile>
|
||||||
|
<DashboardTile spanMobile={1} spanDesktop={2}>
|
||||||
|
<RestDaysWidget />
|
||||||
|
</DashboardTile>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Indikatoren</div>
|
||||||
|
<div className="dashboard-pill-row">
|
||||||
|
{pills.map((p, i) => (
|
||||||
|
<Pill key={i} {...p} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16, fontSize: 13, color: 'var(--text3)' }}>
|
||||||
|
Mehr Gewichts- und Ernährungsdaten für den Trend nötig.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Trends</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Kalorien und Gewicht ({windowDays} Tage)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => nav('/history', { state: { tab: 'body' } })}
|
||||||
|
>
|
||||||
|
Details →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<TrendKcalWeightChart weights={weights} nutrition={nutrition} windowDays={windowDays} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 2,
|
||||||
|
background: '#EF9F27',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Ø Kalorien
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 2,
|
||||||
|
background: '#378ADD',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Gewicht
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/components/pilot/PilotActivitySection.jsx
Normal file
132
frontend/src/components/pilot/PilotActivitySection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-gap" style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '2px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Aktivität</h2>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
|
||||||
|
Trainingstyp-Verteilung {periodDays} Tage · Bewertung Konsistenz wie im Verlauf
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aktiver Qualitätsfilter im Profil – Aktivitätsdaten entsprechend gefiltert.
|
||||||
|
<Link to="/settings" style={{ marginLeft: 8, color: 'var(--accent)' }}>
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Trainingstyp-Verteilung</div>
|
||||||
|
<TrainingTypeDistribution days={periodDays} />
|
||||||
|
<div style={{ marginTop: 8, textAlign: 'right' }}>
|
||||||
|
<Link to="/history" state={{ tab: 'activity' }} style={{ fontSize: 12, color: 'var(--accent)' }}>
|
||||||
|
Vollständiger Verlauf Aktivität →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Bewertung · Aktivität</div>
|
||||||
|
{filtA.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Noch keine Aktivitäten.{' '}
|
||||||
|
<Link to="/activity" style={{ color: 'var(--accent)' }}>
|
||||||
|
Training erfassen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
actRules.map((item, i) => <PilotRuleCard key={i} item={item} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
230
frontend/src/components/pilot/PilotBodySection.jsx
Normal file
230
frontend/src/components/pilot/PilotBodySection.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="section-gap" style={{ marginBottom: 24 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
marginBottom: 12,
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '2px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0, color: 'var(--text1)' }}>Bereich Körper</h2>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '6px 0 0', lineHeight: 1.5 }}>
|
||||||
|
Fokus letzte {windowDays} Tage · Gewicht mit Ø 7 / Ø 14 Tage wie im Verlauf
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasWeight && (
|
||||||
|
<div className="card" style={{ padding: 20, fontSize: 13, color: 'var(--text2)' }}>
|
||||||
|
Zu wenig Gewichtsdaten für den Graph.{' '}
|
||||||
|
<Link to="/weight" style={{ color: 'var(--accent)' }}>
|
||||||
|
Gewicht erfassen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasWeight && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||||
|
Gewicht · {filtW.length} Messungen ({windowDays}T)
|
||||||
|
</div>
|
||||||
|
<Link to="/history" state={{ tab: 'body' }} className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px', textDecoration: 'none' }}>
|
||||||
|
Verlauf Körper <ChevronRight size={10} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||||
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||||
|
tickLine={false}
|
||||||
|
interval={Math.max(0, Math.floor(wCd.length / 6) - 1)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||||
|
{avgAll && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={avgAll}
|
||||||
|
stroke="var(--text3)"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth={1}
|
||||||
|
label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeProfile?.goal_weight && (
|
||||||
|
<ReferenceLine
|
||||||
|
y={activeProfile.goal_weight}
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
label={{
|
||||||
|
value: `Ziel ${activeProfile.goal_weight}kg`,
|
||||||
|
fontSize: 9,
|
||||||
|
fill: 'var(--accent)',
|
||||||
|
position: 'right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
|
formatter={(v, n) => [
|
||||||
|
`${v} kg`,
|
||||||
|
n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD' }} name="weight" />
|
||||||
|
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
|
||||||
|
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||||
|
<span>
|
||||||
|
<span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} />
|
||||||
|
Täglich
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />
|
||||||
|
Ø 7T
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||||
|
<svg width="14" height="4">
|
||||||
|
<line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" />
|
||||||
|
</svg>
|
||||||
|
Ø 14T
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
width: 12,
|
||||||
|
height: 2,
|
||||||
|
background: 'var(--text3)',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
marginRight: 3,
|
||||||
|
borderTop: '2px dashed',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Ø Zeitraum
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rules.length > 0 && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Bewertung · Körper</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 4, marginBottom: 10, lineHeight: 1.5 }}>
|
||||||
|
Körperfett, Magermasse (FFMI), BMI – gleiche Logik wie auf der Verlauf-Seite (Körper).
|
||||||
|
</p>
|
||||||
|
{rules.map((item, i) => (
|
||||||
|
<PilotRuleCard key={i} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
222
frontend/src/components/pilot/PilotKpiBoard.jsx
Normal file
222
frontend/src/components/pilot/PilotKpiBoard.jsx
Normal file
|
|
@ -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<string, unknown> }} 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(
|
||||||
|
<div key="kpi-bf" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Körperfett</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: bf.cat?.color || 'var(--text1)' }}>
|
||||||
|
{bf.pct}%
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>{bf.cat?.label || 'Caliper'}</div>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id === 'avg_kcal') {
|
||||||
|
if (avgKcal == null) return
|
||||||
|
out.push(
|
||||||
|
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>
|
||||||
|
Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const tk = parseRefTypeKey(id)
|
||||||
|
if (!tk) return
|
||||||
|
const tile = refByKey.get(tk)
|
||||||
|
if (!tile?.latest) return
|
||||||
|
const l = tile.latest
|
||||||
|
out.push(
|
||||||
|
<div key={`ref-${tk}`} className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>{tile.type_label}</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4 }}>
|
||||||
|
{formatRefVal(l)}
|
||||||
|
{l.unit ? (
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>{l.unit}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ref.wert</div>
|
||||||
|
</div>,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[bf, avgKcal, refByKey],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleTiles = useMemo(() => {
|
||||||
|
const out = []
|
||||||
|
for (const id of orderIds) {
|
||||||
|
pushTileForId(id, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}, [orderIds, pushTileForId])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ textAlign: 'center', padding: 24 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap" style={{ color: 'var(--danger)', fontSize: 13 }}>
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleTiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Kennzahlen</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', margin: 0 }}>
|
||||||
|
Noch keine Daten oder keine passenden Kacheln.{' '}
|
||||||
|
<Link to="/settings/reference-values" style={{ color: 'var(--accent)' }}>
|
||||||
|
Referenzwerte
|
||||||
|
</Link>
|
||||||
|
,{' '}
|
||||||
|
<Link to="/caliper" style={{ color: 'var(--accent)' }}>
|
||||||
|
Caliper
|
||||||
|
</Link>
|
||||||
|
,{' '}
|
||||||
|
<Link to="/nutrition" style={{ color: 'var(--accent)' }}>
|
||||||
|
Ernährung
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Kennzahlen</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
|
{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).`}
|
||||||
|
</p>
|
||||||
|
<div className="ref-value-tiles-grid">{visibleTiles}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
278
frontend/src/components/pilot/PilotQuickCapture.jsx
Normal file
278
frontend/src/components/pilot/PilotQuickCapture.jsx
Normal file
|
|
@ -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<string, unknown> }} 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 (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Schnelleingabe (heute)</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>
|
||||||
|
Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen
|
||||||
|
oder <Link to="/vitals">Vitalwerte-Seite</Link> nutzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Schnelleingabe (heute)</div>
|
||||||
|
{(showWeight || showVitalsBlock) && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
|
{showWeight && showVitalsBlock && 'Gewicht separat; Vitalwerte typischerweise gemeinsam. '}
|
||||||
|
{showWeight && !showVitalsBlock && 'Gewicht für heute. '}
|
||||||
|
{!showWeight && showVitalsBlock && 'Baseline-Vitalwerte für heute. '}
|
||||||
|
<Link to="/vitals" style={{ color: 'var(--accent)', fontSize: 12 }}>
|
||||||
|
Volle Vitalwerte-Seite →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
{showWeight && (
|
||||||
|
<div style={cellStyle}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Gewicht</div>
|
||||||
|
{weightErr && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--danger)', marginBottom: 6 }}>{weightErr}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
min={20}
|
||||||
|
max={300}
|
||||||
|
step={0.1}
|
||||||
|
style={{ flex: 1, minWidth: 72 }}
|
||||||
|
placeholder="kg"
|
||||||
|
value={weightInput}
|
||||||
|
onChange={(e) => setWeightInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && saveWeight()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ padding: '6px 12px' }}
|
||||||
|
disabled={weightSaving}
|
||||||
|
onClick={saveWeight}
|
||||||
|
>
|
||||||
|
{weightSaved ? <Check size={15} /> : weightSaving ? <div className="spinner" style={{ width: 14, height: 14 }} /> : 'OK'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVitalsBlock && (
|
||||||
|
<div style={{ ...cellStyle, flex: showWeight ? '2 1 280px' : '1 1 280px' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Vitalwerte (Baseline)
|
||||||
|
</div>
|
||||||
|
{vErr && <div style={{ fontSize: 11, color: 'var(--danger)', marginBottom: 6 }}>{vErr}</div>}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||||
|
gap: '12px 10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showRestingHr && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="pqc-resting-hr"
|
||||||
|
className="form-label"
|
||||||
|
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||||
|
>
|
||||||
|
Ruhepuls
|
||||||
|
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> (bpm)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pqc-resting-hr"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={vForm.resting_hr}
|
||||||
|
onChange={(e) => setVForm((f) => ({ ...f, resting_hr: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showHrv && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="pqc-hrv"
|
||||||
|
className="form-label"
|
||||||
|
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||||
|
>
|
||||||
|
HRV
|
||||||
|
<span style={{ fontWeight: 400, color: 'var(--text3)' }}> (ms)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pqc-hrv"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={vForm.hrv}
|
||||||
|
onChange={(e) => setVForm((f) => ({ ...f, hrv: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showVo2 && (
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="pqc-vo2"
|
||||||
|
className="form-label"
|
||||||
|
style={{ display: 'block', marginBottom: 4, fontSize: 11, fontWeight: 600, color: 'var(--text2)' }}
|
||||||
|
>
|
||||||
|
VO₂max
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="pqc-vo2"
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
step={0.1}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={vForm.vo2_max}
|
||||||
|
onChange={(e) => setVForm((f) => ({ ...f, vo2_max: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ marginTop: 10 }}
|
||||||
|
disabled={vSaving}
|
||||||
|
onClick={saveVitals}
|
||||||
|
>
|
||||||
|
{vOk ? '✓ Gespeichert' : vSaving ? '…' : 'Vitalwerte speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
frontend/src/components/pilot/PilotRuleCard.jsx
Normal file
59
frontend/src/components/pilot/PilotRuleCard.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div style={{ border: `1px solid ${color}33`, borderRadius: 8, marginBottom: 6, overflow: 'hidden' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '8px 12px',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
background: `${getStatusBg(item.status)}88`,
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 15 }}>{item.icon}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
color,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.category}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--text1)' }}>{item.title}</div>
|
||||||
|
</div>
|
||||||
|
{item.value && <span style={{ fontSize: 14, fontWeight: 700, color }}>{item.value}</span>}
|
||||||
|
{open ? <ChevronUp size={14} color="var(--text3)" /> : <ChevronDown size={14} color="var(--text3)" />}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
borderTop: `1px solid ${color}22`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
frontend/src/components/pilot/PilotWelcome.jsx
Normal file
19
frontend/src/components/pilot/PilotWelcome.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<h2 style={{ fontSize: 20, fontWeight: 800, margin: 0, color: 'var(--text1)' }}>
|
||||||
|
Hallo, {activeProfile?.name || 'Nutzer'} 👋
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '6px 0 0' }}>
|
||||||
|
{dayjs().format('dddd, DD. MMMM YYYY')} · Pilot-Übersicht
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -86,6 +86,11 @@ export const ADMIN_GROUPS = [
|
||||||
label: 'Focus Areas',
|
label: 'Focus Areas',
|
||||||
description: 'Dynamische Fokusbereiche und Kategorien.',
|
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',
|
label: 'SMTP & Metadaten-Export',
|
||||||
description: 'SMTP-Status, Test-Mail und Placeholder-Katalog (JSON/ZIP).',
|
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).',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
10
frontend/src/config/settingsNav.js
Normal file
10
frontend/src/config/settingsNav.js
Normal file
|
|
@ -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' },
|
||||||
|
]
|
||||||
33
frontend/src/layouts/SettingsShell.jsx
Normal file
33
frontend/src/layouts/SettingsShell.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="settings-shell">
|
||||||
|
<div className="analysis-split">
|
||||||
|
<div className="analysis-split__nav-wrap">
|
||||||
|
<nav className="analysis-split__nav" aria-label="Einstellungen">
|
||||||
|
{SETTINGS_SHELL_NAV_ITEMS.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.id}
|
||||||
|
to={item.to}
|
||||||
|
end={!!item.end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
'analysis-split__nav-item' + (isActive ? ' analysis-split__nav-item--active' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div className="analysis-split__main">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,8 +14,19 @@ const CATEGORIES = [
|
||||||
{ value: 'custom', label: 'Eigene' }
|
{ 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() {
|
export default function AdminFocusAreasPage() {
|
||||||
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
||||||
|
const [usageTypesCatalog, setUsageTypesCatalog] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showInactive, setShowInactive] = useState(false)
|
const [showInactive, setShowInactive] = useState(false)
|
||||||
|
|
@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() {
|
||||||
loadData()
|
loadData()
|
||||||
}, [showInactive])
|
}, [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 () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await api.listFocusAreaDefinitions(showInactive)
|
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)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load focus areas:', err)
|
console.error('Failed to load focus areas:', err)
|
||||||
|
|
@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() {
|
||||||
const handleUpdate = async (id) => {
|
const handleUpdate = async (id) => {
|
||||||
try {
|
try {
|
||||||
const area = data.areas.find(a => a.id === id)
|
const area = data.areas.find(a => a.id === id)
|
||||||
await api.updateFocusAreaDefinition(id, {
|
const usageKeys = Array.isArray(area.allowed_usage_type_keys)
|
||||||
name_de: area.name_de,
|
? area.allowed_usage_type_keys
|
||||||
name_en: area.name_en,
|
: []
|
||||||
icon: area.icon,
|
await Promise.all([
|
||||||
description: area.description,
|
api.updateFocusAreaDefinition(id, {
|
||||||
category: area.category,
|
name_de: area.name_de,
|
||||||
is_active: area.is_active
|
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)
|
setEditingId(null)
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateField = (id, field, value) => {
|
const updateField = (id, field, value) => {
|
||||||
setData(prev => ({
|
setData(prev => {
|
||||||
...prev,
|
const areas = prev.areas.map(a =>
|
||||||
areas: prev.areas.map(a =>
|
|
||||||
a.id === id ? { ...a, [field]: value } : 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) {
|
if (loading) {
|
||||||
|
|
@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 12, fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||||
|
Erlaubte Nutzungstypen
|
||||||
|
</label>
|
||||||
|
{usageTypesCatalog.length === 0 ? (
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||||
|
Kein Katalog geladen (Backend / Migration prüfen).
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{usageTypesCatalog.map(ut => (
|
||||||
|
<label
|
||||||
|
key={ut.id}
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 8,
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
checked={(area.allowed_usage_type_keys || []).includes(ut.key)}
|
||||||
|
onChange={(e) =>
|
||||||
|
toggleUsageTypeKey(area.id, ut.key, e.target.checked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span style={{ display: 'block' }}>{ut.label_de}</span>
|
||||||
|
<code style={{ fontSize: 11, color: 'var(--text3)' }}>{ut.key}</code>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
|
|
@ -430,6 +523,37 @@ export default function AdminFocusAreasPage() {
|
||||||
{area.description}
|
{area.description}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{(area.allowed_usage_type_keys || []).length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 6
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(area.allowed_usage_type_keys || []).map(k => {
|
||||||
|
const ut = usageTypesCatalog.find(x => x.key === k)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={k}
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
color: 'var(--text2)'
|
||||||
|
}}
|
||||||
|
title={k}
|
||||||
|
>
|
||||||
|
{ut?.label_de || k}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
|
|
|
||||||
607
frontend/src/pages/AdminReferenceValueTypesPage.jsx
Normal file
607
frontend/src/pages/AdminReferenceValueTypesPage.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<div className="settings-page__field" style={{ flex: '1 1 200px', border: 'none', padding: 0 }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-vr-min">
|
||||||
|
Minimum (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-vr-min"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="form-input"
|
||||||
|
value={form.vr_min}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_min: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field" style={{ flex: '1 1 200px', border: 'none', padding: 0 }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-vr-max">
|
||||||
|
Maximum (optional){t === 'percentage' ? ' · global max. 100' : ''}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-vr-max"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="form-input"
|
||||||
|
value={form.vr_max}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_max: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field" style={{ border: 'none', padding: '8px 0 0' }}>
|
||||||
|
<span className="settings-page__field-label">Optionen</span>
|
||||||
|
<label
|
||||||
|
htmlFor="ref-vr-pos"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 14 }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ref-vr-pos"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.vr_positive_only}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_positive_only: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Nur positive Zahlen (> 0)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (t === 'text') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="settings-page__field" style={{ border: 'none', padding: 0 }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-vr-maxlen">
|
||||||
|
Max. Zeichenzahl (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-vr-maxlen"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="form-input"
|
||||||
|
value={form.vr_max_length}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_max_length: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field" style={{ border: 'none', padding: '8px 0 0' }}>
|
||||||
|
<label htmlFor="ref-vr-ne" style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 14 }}>
|
||||||
|
<input
|
||||||
|
id="ref-vr-ne"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.vr_not_empty}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_not_empty: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Nicht leer erlauben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (t === 'enum') {
|
||||||
|
return (
|
||||||
|
<div className="settings-page__field" style={{ border: 'none', padding: 0 }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-vr-enum">
|
||||||
|
Erlaubte Werte (kommagetrennt)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-vr-enum"
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={form.vr_enum_list}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, vr_enum_list: e.target.value }))}
|
||||||
|
placeholder="z. B. niedrig, mittel, hoch"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <p style={{ fontSize: 13, color: 'var(--text3)', margin: 0 }}>Keine zusätzlichen Regeln für diesen Typ.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ textAlign: 'left' }}>
|
||||||
|
<div className="page-header" style={{ marginBottom: 16 }}>
|
||||||
|
<h1 style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 22, margin: 0 }}>
|
||||||
|
<Gauge size={26} color="var(--accent)" />
|
||||||
|
Referenz-Kennwerte (Typen)
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.6 }}>
|
||||||
|
Kategorie, Datentyp und Plausibilisierung steuern die Nutzererfassung. Die Standard-Einheit ist bei der
|
||||||
|
Eingabe fix; Prozentwerte liegen grundsätzlich zwischen 0 und 100.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: 12, display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<Link to="/admin/g/goals" className="btn btn-secondary" style={{ fontSize: 13 }}>
|
||||||
|
← Zur Gruppe „Ziele & Fokus“
|
||||||
|
</Link>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={openCreate}>
|
||||||
|
<Plus size={16} style={{ marginRight: 6 }} />
|
||||||
|
Neuer Typ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: 'var(--accent-light)',
|
||||||
|
color: 'var(--accent-dark)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="card" style={{ background: '#FEF2F2', border: '1px solid #FCA5A5', marginBottom: 16 }}>
|
||||||
|
<p style={{ color: '#DC2626', margin: 0, fontSize: 14 }}>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showForm && (
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="card-title" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
{editingId ? 'Typ bearbeiten' : 'Neuer Typ'}
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={closeForm} style={{ padding: '6px 10px' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-key">
|
||||||
|
Technischer Schlüssel (key)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-key"
|
||||||
|
className="form-input"
|
||||||
|
disabled={!!editingId}
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
|
||||||
|
placeholder="z. B. max_heart_rate"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '4px 0 0', textAlign: 'left' }}>
|
||||||
|
Kleinbuchstaben, Ziffern, Unterstriche; nach Anlage nicht änderbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-label">
|
||||||
|
Anzeigename
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-label"
|
||||||
|
className="form-input"
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||||
|
placeholder="z. B. Maximale Herzfrequenz"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-cat">
|
||||||
|
Kategorie (Freitext)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-cat"
|
||||||
|
className="form-input"
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
|
||||||
|
placeholder="z. B. Herz-Kreislauf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-desc">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-admin-desc"
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
placeholder="Kurze Erklärung für Nutzer und Admins"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-vdt">
|
||||||
|
Datentyp des Werts
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-admin-vdt"
|
||||||
|
className="form-input"
|
||||||
|
value={form.value_data_type}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value_data_type: e.target.value }))}
|
||||||
|
>
|
||||||
|
{VALUE_TYPES.map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{VALUE_DATA_TYPE_LABELS[k] || k}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label">Plausibilisierung (je Datentyp)</label>
|
||||||
|
{plausibilisierungBlock()}
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-unit">
|
||||||
|
Standard-Einheit (fix bei Nutzererfassung)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-admin-unit"
|
||||||
|
className="form-input"
|
||||||
|
value={form.default_unit}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, default_unit: e.target.value }))}
|
||||||
|
placeholder="bpm, %, Stufe, …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<span className="settings-page__field-label">Sichtbarkeit</span>
|
||||||
|
<label
|
||||||
|
htmlFor="ref-admin-active"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--text1)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="ref-admin-active"
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.active}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, active: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Aktiv (für Nutzer sichtbar)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-admin-meta">
|
||||||
|
Zusatz-Metadaten (JSON-Objekt, optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-admin-meta"
|
||||||
|
className="form-input"
|
||||||
|
rows={4}
|
||||||
|
value={form.metadata_json}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, metadata_json: e.target.value }))}
|
||||||
|
placeholder="{}"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="button" className="btn btn-primary btn-full" onClick={handleSave}>
|
||||||
|
<Save size={16} style={{ marginRight: 6 }} />
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" onClick={closeForm}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-title">Alle Typen ({rows.length})</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 0, marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
|
Reihenfolge in der Liste und in den Nutzer-Dropdowns: Zeile mit <strong>hoch/runter</strong> verschieben.
|
||||||
|
</p>
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
|
<th style={{ padding: '8px 4px', width: 44 }} aria-label="Reihenfolge" title="Reihenfolge" />
|
||||||
|
<th style={{ padding: '8px 6px' }}>Key</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Name</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Kategorie</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Typ</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Einheit</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Aktiv</th>
|
||||||
|
<th style={{ padding: '8px 6px' }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<tr key={r.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '6px 4px', verticalAlign: 'middle' }}>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 6px', minHeight: 0, lineHeight: 1 }}
|
||||||
|
disabled={reorderBusy || i === 0}
|
||||||
|
title="Nach oben"
|
||||||
|
onClick={() => moveRow(i, 'up')}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 6px', minHeight: 0, lineHeight: 1 }}
|
||||||
|
disabled={reorderBusy || i === rows.length - 1}
|
||||||
|
title="Nach unten"
|
||||||
|
onClick={() => moveRow(i, 'down')}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 6px', fontFamily: 'monospace', fontSize: 13 }}>{r.key}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{r.label}</td>
|
||||||
|
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.category || '–'}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>
|
||||||
|
{VALUE_DATA_TYPE_LABELS[r.value_data_type] || r.value_data_type || '–'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{r.default_unit || '–'}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{r.active ? '✓' : '–'}</td>
|
||||||
|
<td style={{ padding: '6px', textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', marginRight: 6 }}
|
||||||
|
onClick={() => openEdit(r)}
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', color: 'var(--danger)' }}
|
||||||
|
onClick={() => handleDelete(r)}
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0, fontSize: 14 }}>Noch keine Typen – „Neuer Typ“ anlegen.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,307 +1,56 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { Check, Brain } from 'lucide-react'
|
import { LayoutDashboard } from 'lucide-react'
|
||||||
import {
|
|
||||||
LineChart, Line, XAxis, YAxis, Tooltip,
|
|
||||||
ResponsiveContainer, CartesianGrid
|
|
||||||
} from 'recharts'
|
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { getBfCategory } from '../utils/calc'
|
|
||||||
import TrialBanner from '../components/TrialBanner'
|
import TrialBanner from '../components/TrialBanner'
|
||||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
import SleepWidget from '../components/SleepWidget'
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
import RestDaysWidget from '../components/RestDaysWidget'
|
|
||||||
import Markdown from '../utils/Markdown'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import 'dayjs/locale/de'
|
|
||||||
import DashboardSection from '../components/DashboardSection'
|
|
||||||
import DashboardTile from '../components/DashboardTile'
|
|
||||||
import {
|
|
||||||
clampTileSpan,
|
|
||||||
DASHBOARD_TILE_GRID_COLS,
|
|
||||||
dashboardStatGridClassName,
|
|
||||||
dashboardTileGridClassName
|
|
||||||
} from '../utils/dashboardLayout'
|
|
||||||
dayjs.locale('de')
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
function catalogMetaById(catalog) {
|
||||||
function rollingAvg(arr, key, w=7) {
|
if (!catalog?.widgets?.length) return {}
|
||||||
return arr.map((d,i)=>{
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Quick Weight Entry ────────────────────────────────────────────────────────
|
|
||||||
function QuickWeight({ 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()
|
|
||||||
},[])
|
|
||||||
|
|
||||||
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() // Reload usage after save
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
{error && (
|
|
||||||
<div style={{padding:'8px 10px',background:'var(--danger-bg)',border:'1px solid var(--danger)',borderRadius:8,fontSize:12,color:'var(--danger)',marginBottom:8}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{display:'flex',gap:8,alignItems:'center'}}>
|
|
||||||
<input type="number" min={20} max={300} step={0.1} className="form-input"
|
|
||||||
style={{flex:1,fontSize:17,fontWeight:600,textAlign:'center'}}
|
|
||||||
placeholder="kg eingeben" value={input} onChange={e=>setInput(e.target.value)}
|
|
||||||
onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/>
|
|
||||||
<span style={{fontSize:13,color:'var(--text3)'}}>kg</span>
|
|
||||||
<div title={tooltipText} style={{display:'inline-block'}}>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
style={{padding:'8px 14px', cursor: isDisabled ? 'not-allowed' : 'pointer'}}
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isDisabled}
|
|
||||||
>
|
|
||||||
{saved ? <Check size={15}/>
|
|
||||||
: saving ? <div className="spinner" style={{width:14,height:14}}/>
|
|
||||||
: (weightUsage && !weightUsage.allowed) ? '🔒 Limit'
|
|
||||||
: 'Speichern'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status Pill ───────────────────────────────────────────────────────────────
|
|
||||||
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).',
|
|
||||||
}
|
|
||||||
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 (
|
|
||||||
<div style={{position:'relative'}}>
|
|
||||||
<div onClick={()=>tipText&&setTip(s=>!s)}
|
|
||||||
style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',
|
|
||||||
borderRadius:20,background:bg,border:`1px solid ${color}44`,
|
|
||||||
cursor:tipText?'help':'default'}}>
|
|
||||||
<div style={{width:7,height:7,borderRadius:'50%',background:color,flexShrink:0}}/>
|
|
||||||
<span style={{fontSize:12,fontWeight:500,color:'var(--text2)'}}>{label}</span>
|
|
||||||
<span style={{fontSize:12,fontWeight:700,color}}>{value}</span>
|
|
||||||
{sub && <span style={{fontSize:10,color:'var(--text3)'}}>{sub}</span>}
|
|
||||||
{tipText && <span style={{fontSize:10,color:'var(--text3)',opacity:0.7}}>ⓘ</span>}
|
|
||||||
</div>
|
|
||||||
{tip && tipText && (
|
|
||||||
<div onClick={()=>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)'}}>
|
|
||||||
<strong>{label}</strong><br/>{tipText}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stat Card ─────────────────────────────────────────────────────────────────
|
|
||||||
/**
|
|
||||||
* KPI-Kachel im Dashboard-Raster (`dashboard-stat-grid` / `dashboard-tile-grid`).
|
|
||||||
* @param {number} [spanMobile=1] Spaltenbreite unter 1024px (max. = Raster-Spalten mobile)
|
|
||||||
* @param {number} [spanDesktop=1] Spaltenbreite ≥1024px (max. 4)
|
|
||||||
*/
|
|
||||||
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 (
|
|
||||||
<div
|
|
||||||
className="dashboard-stat-card"
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
'--tile-sm': String(sm),
|
|
||||||
'--tile-lg': String(lg)
|
|
||||||
}}
|
|
||||||
onMouseEnter={e=>onClick&&(e.currentTarget.style.borderColor='var(--accent)')}
|
|
||||||
onMouseLeave={e=>onClick&&(e.currentTarget.style.borderColor='var(--border)')}>
|
|
||||||
<div style={{fontSize:18,marginBottom:4}}>{icon}</div>
|
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:2}}>{label}</div>
|
|
||||||
<div style={{fontSize:19,fontWeight:700,color:color||'var(--text1)',lineHeight:1.1}}>
|
|
||||||
{value}<span style={{fontSize:12,fontWeight:400,color:'var(--text3)',marginLeft:2}}>{unit}</span>
|
|
||||||
</div>
|
|
||||||
{delta!=null && <div style={{fontSize:11,fontWeight:600,color:deltaColor,marginTop:2}}>
|
|
||||||
{delta>0?'+':''}{delta} {unit}
|
|
||||||
</div>}
|
|
||||||
{sub && <div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{sub}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Combined Chart: Kcal + Weight ─────────────────────────────────────────────
|
|
||||||
function ComboChart({ weights, nutrition }) {
|
|
||||||
// Build unified date axis from last 30 days
|
|
||||||
const days = []
|
|
||||||
for (let i=29; 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(n=>{ nMap[n.date]=Math.round(n.kcal||0) })
|
|
||||||
|
|
||||||
// Forward-fill weight: carry last known weight to fill gaps
|
|
||||||
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, // actual measurement dots
|
|
||||||
weightLine:lastW, // interpolated line
|
|
||||||
}
|
|
||||||
}).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 (
|
|
||||||
<div style={{padding:20,textAlign:'center',fontSize:12,color:'var(--text3)'}}>
|
|
||||||
Mehr Ernährungs- und Gewichtsdaten für den Chart nötig
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ResponsiveContainer width="100%" height={160}>
|
|
||||||
<LineChart data={withAvg} margin={{top:4,right:8,bottom:0,left:-20}}>
|
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
|
||||||
interval={Math.max(0,Math.floor(withAvg.length/6)-1)}/>
|
|
||||||
{hasKcal && <YAxis yAxisId="kcal" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
|
||||||
{hasW && <YAxis yAxisId="weight" orientation="right" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={['auto','auto']}/>}
|
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
|
||||||
formatter={(v,n)=>[v==null?'–':`${Math.round(v)} ${n==='weightLine'||n==='weight'?'kg':'kcal'}`,
|
|
||||||
n==='kcal_avg'?'Ø Kalorien (7T)':n==='kcal'?'Kalorien':n==='weightLine'?'Gewicht (interpoliert)':'Gewicht Messung']}/>
|
|
||||||
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} connectNulls={false}/>}
|
|
||||||
{hasKcal && <Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} connectNulls={true} name="kcal_avg"/>}
|
|
||||||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weightLine" stroke="#378ADD88" strokeWidth={1.5} dot={false} connectNulls={true} name="weightLine"/>}
|
|
||||||
{hasW && <Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={0}
|
|
||||||
dot={(props)=>{ const {cx,cy,value}=props; return value!=null?<circle key={cx} cx={cx} cy={cy} r={4} fill="#378ADD" stroke="white" strokeWidth={1.5}/>:<g key={cx}/>}} connectNulls={false} name="weight"/>}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Dashboard ────────────────────────────────────────────────────────────
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const nav = useNavigate()
|
const nav = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { activeProfile } = useProfile()
|
const { activeProfile } = useProfile()
|
||||||
|
|
||||||
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
|
const [adminDeniedHint, setAdminDeniedHint] = useState(false)
|
||||||
const [goalsCount, setGoalsCount] = useState(null)
|
const [layoutBundle, setLayoutBundle] = useState(null)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [layoutLoading, setLayoutLoading] = useState(true)
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
|
||||||
const [stats, setStats] = useState(null)
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
const [weights, setWeights] = useState([])
|
|
||||||
const [calipers, setCalipers] = useState([])
|
|
||||||
const [circs, setCircs] = useState([])
|
|
||||||
const [nutrition, setNutrition] = useState([])
|
|
||||||
const [activities,setActivities]= useState([])
|
|
||||||
const [insights, setInsights] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [showInsight, setShowInsight] = useState(false)
|
|
||||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
|
||||||
const [pipelineError, setPipelineError] = useState(null)
|
|
||||||
|
|
||||||
const load = () => Promise.all([
|
useEffect(() => {
|
||||||
api.getStats(),
|
ensurePilotLabWidgetsRegistered()
|
||||||
api.listWeight(60),
|
}, [])
|
||||||
api.listCaliper(3),
|
|
||||||
api.listCirc(2),
|
|
||||||
api.listNutrition(30),
|
|
||||||
api.listActivity(30),
|
|
||||||
api.latestInsights(),
|
|
||||||
]).then(([s,w,ca,ci,n,a,ins])=>{
|
|
||||||
setStats(s); setWeights(w); setCalipers(ca); setCircs(ci)
|
|
||||||
setNutrition(n); setActivities(a)
|
|
||||||
setInsights(Array.isArray(ins)?ins:[])
|
|
||||||
setLoading(false)
|
|
||||||
}).catch(err => {
|
|
||||||
console.error('Dashboard load failed:', err)
|
|
||||||
// Set empty data on error so UI can still render
|
|
||||||
setStats(null); setWeights([]); setCalipers([]); setCircs([])
|
|
||||||
setNutrition([]); setActivities([]); setInsights([])
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const runPipeline = async () => {
|
useEffect(() => {
|
||||||
setPipelineLoading(true); setPipelineError(null)
|
let cancel = false
|
||||||
try {
|
setLayoutLoading(true)
|
||||||
await api.insightPipeline()
|
Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
|
||||||
await load()
|
.then(([b, c]) => {
|
||||||
} catch(e) {
|
if (cancel) return
|
||||||
setPipelineError('Fehler: '+e.message)
|
setLayoutBundle(b)
|
||||||
} finally { setPipelineLoading(false) }
|
setCatalog(c)
|
||||||
}
|
})
|
||||||
|
.catch(() => {
|
||||||
useEffect(()=>{ load() },[])
|
if (cancel) return
|
||||||
|
setLayoutBundle(null)
|
||||||
|
setCatalog(null)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancel) setLayoutLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancel = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!location.state?.adminDenied) return
|
if (!location.state?.adminDenied) return
|
||||||
|
|
@ -311,60 +60,19 @@ export default function Dashboard() {
|
||||||
return () => window.clearTimeout(clear)
|
return () => window.clearTimeout(clear)
|
||||||
}, [location.state, nav])
|
}, [location.state, nav])
|
||||||
|
|
||||||
useEffect(() => {
|
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
|
||||||
if (!activeProfile?.id) return
|
|
||||||
api.listGoals()
|
|
||||||
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
|
|
||||||
.catch(() => setGoalsCount(null))
|
|
||||||
}, [activeProfile?.id])
|
|
||||||
|
|
||||||
if (loading) return <div className="empty-state"><div className="spinner"/></div>
|
const layoutForPreview = useMemo(() => {
|
||||||
|
if (!layoutBundle?.layout) return null
|
||||||
const latestCal = calipers[0]
|
const L = layoutBundle.layout
|
||||||
const latestCir = circs[0]
|
return {
|
||||||
const latestW = weights[0]
|
...L,
|
||||||
const prevW = weights[1]
|
widgets: L.widgets.map((w) => ({
|
||||||
const sex = activeProfile?.sex||'m'
|
...w,
|
||||||
const height = activeProfile?.height||178
|
enabled: w.enabled && metaById[w.id]?.allowed !== false,
|
||||||
|
})),
|
||||||
// Deltas
|
}
|
||||||
const wDelta = latestW&&prevW ? Math.round((latestW.weight-prevW.weight)*10)/10 : null
|
}, [layoutBundle, metaById])
|
||||||
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
|
|
||||||
|
|
||||||
// WHR / WHtR
|
|
||||||
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
|
|
||||||
|
|
||||||
// Nutrition averages (last 7 days)
|
|
||||||
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((latestW?.weight||80)*1.6)
|
|
||||||
const proteinOk = avgProtein && avgProtein >= ptLow
|
|
||||||
|
|
||||||
// Activity (last 7 days)
|
|
||||||
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
|
|
||||||
|
|
||||||
// Status pills
|
|
||||||
const pills = []
|
|
||||||
if (whr) pills.push({label:'WHR', value:whr, status:whr<(sex==='m'?0.90: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) pills.push({label:'KF', value:latestCal.body_fat_pct+'%', status:latestCal.body_fat_pct<(sex==='m'?18:25)?'good':'warn', sub:bfCat.label})
|
|
||||||
|
|
||||||
// Latest overall insight
|
|
||||||
const latestInsight = insights.find(i=>i.scope==='gesamt')||insights[0]
|
|
||||||
|
|
||||||
const hasAnyData = latestW||latestCal||nutrition.length>0
|
|
||||||
|
|
||||||
const showNutrSummary = !!(avgKcal || avgProtein)
|
|
||||||
const showActSummary = actKcal != null
|
|
||||||
const summaryBoth = showNutrSummary && showActSummary
|
|
||||||
const summarySpanM = summaryBoth ? 1 : 2
|
|
||||||
const summarySpanD = summaryBoth ? 2 : 4
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-page">
|
<div className="dashboard-page">
|
||||||
|
|
@ -382,296 +90,41 @@ export default function Dashboard() {
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle.
|
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
|
||||||
Du wurdest zur Übersicht weitergeleitet.
|
Übersicht weitergeleitet.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Header greeting */}
|
|
||||||
<div className="dashboard-greeting">
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
|
||||||
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
<Link
|
||||||
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
to="/settings/dashboard-layout"
|
||||||
</h1>
|
className="btn btn-secondary"
|
||||||
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
style={{
|
||||||
{dayjs().format('dddd, DD. MMMM YYYY')}
|
fontSize: 12,
|
||||||
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
|
padding: '8px 12px',
|
||||||
</div>
|
textDecoration: 'none',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={16} />
|
||||||
|
Übersicht anpassen
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Verification Banner */}
|
{activeProfile && <EmailVerificationBanner profile={activeProfile} />}
|
||||||
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
|
{activeProfile && <TrialBanner profile={activeProfile} />}
|
||||||
|
|
||||||
{/* Trial Banner */}
|
{layoutLoading && (
|
||||||
{activeProfile && <TrialBanner profile={activeProfile}/>}
|
|
||||||
|
|
||||||
{!hasAnyData && (
|
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Willkommen bei Mitai Jinkendo!</h3>
|
<div className="spinner" />
|
||||||
<p>Starte mit deiner ersten Messung.</p>
|
|
||||||
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
|
|
||||||
Erfassen starten
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{hasAnyData && <>
|
{!layoutLoading && layoutForPreview && (
|
||||||
<DashboardSection
|
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||||
title="Gewicht heute"
|
)}
|
||||||
description="Tageswert erfassen – Grundlage für Trends und Ziele."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary"
|
|
||||||
style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={() => nav('/weight')}>
|
|
||||||
Alle Einträge →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<QuickWeight onSaved={load}/>
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Kennzahlen"
|
|
||||||
description="Aktuelle Messwerte und Ernährungs-Schnitt (7 Tage)."
|
|
||||||
>
|
|
||||||
<div className={dashboardStatGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}>
|
|
||||||
<StatCard icon="⚖️" label="Gewicht" value={latestW?.weight??'–'} unit="kg"
|
|
||||||
delta={wDelta} deltaGoodWhenNeg={true}
|
|
||||||
sub={latestW ? dayjs(latestW.date).format('DD.MM.') : '–'}
|
|
||||||
onClick={()=>nav('/history')} color="#378ADD"/>
|
|
||||||
{latestCal?.body_fat_pct && <StatCard icon="🫧" label="Körperfett" value={latestCal.body_fat_pct} unit="%"
|
|
||||||
delta={bfDelta} deltaGoodWhenNeg={true}
|
|
||||||
sub={bfCat?.label}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})} color={bfCat?.color}/>}
|
|
||||||
{latestCal?.lean_mass && <StatCard icon="💪" label="Magermasse" value={latestCal.lean_mass} unit="kg"
|
|
||||||
sub={latestCal.date ? dayjs(latestCal.date).format('DD.MM.') : '–'}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})}/>}
|
|
||||||
{avgKcal && <StatCard icon="🍽️" label="Ø Kalorien" value={avgKcal} unit="kcal"
|
|
||||||
sub="letzte 7 Tage" onClick={()=>nav('/history',{state:{tab:'nutrition'}})} color="#EF9F27"/>}
|
|
||||||
</div>
|
|
||||||
{pills.length > 0 && (
|
|
||||||
<div className="dashboard-pill-row">
|
|
||||||
{pills.map((p,i)=><Pill key={i} {...p}/>)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
{(activeProfile?.goal_weight||activeProfile?.goal_bf_pct) && latestW && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Profil-Ziele"
|
|
||||||
description="Fortschritt zu den Zielwerten in deinem Profil."
|
|
||||||
>
|
|
||||||
<div className="card section-gap">
|
|
||||||
{activeProfile?.goal_weight && latestW && (()=>{
|
|
||||||
const start = Math.max(...weights.map(w=>w.weight))
|
|
||||||
const curr = latestW.weight
|
|
||||||
const goal = activeProfile.goal_weight
|
|
||||||
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 (
|
|
||||||
<div style={{marginBottom:10}}>
|
|
||||||
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
|
|
||||||
<span>Gewicht: {curr} → {goal} kg</span>
|
|
||||||
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}kg`:'Ziel erreicht! 🎉'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
|
|
||||||
<div style={{height:'100%',width:`${pct}%`,background:'var(--accent)',borderRadius:4,transition:'width 0.5s'}}/>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>{pct}% des Weges</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{activeProfile?.goal_bf_pct && latestCal?.body_fat_pct && (()=>{
|
|
||||||
const curr = latestCal.body_fat_pct
|
|
||||||
const goal = activeProfile.goal_bf_pct
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<div style={{display:'flex',justifyContent:'space-between',fontSize:12,marginBottom:4}}>
|
|
||||||
<span>Körperfett: {curr}% → {goal}%</span>
|
|
||||||
<span style={{color:'var(--accent)',fontWeight:600}}>{remain>0?`noch ${remain}%`:'Ziel erreicht! 🎉'}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{height:8,background:'var(--border)',borderRadius:4,overflow:'hidden'}}>
|
|
||||||
<div style={{height:'100%',width:`${pct}%`,background:bfCat?.color||'var(--accent)',borderRadius:4}}/>
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:2}}>Aktuell: {bfCat?.label}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(weights.length>2||nutrition.length>2) && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Trends"
|
|
||||||
description="Kalorien und Gewicht der letzten 30 Tage."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={()=>nav('/history',{state:{tab:'body'}})}>
|
|
||||||
Details →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<ComboChart weights={weights} nutrition={nutrition}/>
|
|
||||||
<div style={{display:'flex',gap:16,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
|
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#EF9F27',verticalAlign:'middle',marginRight:3}}/>Ø Kalorien</span>
|
|
||||||
<span><span style={{display:'inline-block',width:12,height:2,background:'#378ADD',verticalAlign:'middle',marginRight:3}}/>Gewicht</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(showNutrSummary || showActSummary) && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Ernährung & Aktivität"
|
|
||||||
description="Kurzüberblick; volle Verläufe unter Historie."
|
|
||||||
>
|
|
||||||
<div className={`dashboard-summary-row ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
|
||||||
{showNutrSummary && (
|
|
||||||
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
|
||||||
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'nutrition'}})}>
|
|
||||||
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🍽️ ERNÄHRUNG (Ø 7T)</div>
|
|
||||||
{avgKcal && <div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{avgKcal} kcal</div>}
|
|
||||||
{avgProtein && <div style={{fontSize:13,fontWeight:600,
|
|
||||||
color:proteinOk?'var(--accent)':'var(--warn)'}}>
|
|
||||||
{avgProtein}g Protein {proteinOk?'✓':'⚠️'}
|
|
||||||
</div>}
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}>→ Verlauf Ernährung</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
)}
|
|
||||||
{showActSummary && (
|
|
||||||
<DashboardTile spanMobile={summarySpanM} spanDesktop={summarySpanD}>
|
|
||||||
<div className="card" style={{ cursor: 'pointer', height: '100%' }} onClick={()=>nav('/history',{state:{tab:'activity'}})}>
|
|
||||||
<div style={{fontWeight:600,fontSize:12,marginBottom:8,color:'var(--text3)'}}>🏋️ AKTIVITÄT (7T)</div>
|
|
||||||
<div style={{fontSize:16,fontWeight:700,color:'#EF9F27'}}>{actKcal} kcal</div>
|
|
||||||
<div style={{fontSize:13,color:'var(--text2)'}}>{recentAct.length} Trainings</div>
|
|
||||||
<div style={{fontSize:10,color:'var(--text3)',marginTop:4}}>→ Verlauf Aktivität</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Erholung"
|
|
||||||
description="Schlaf und Ruhetage im Überblick."
|
|
||||||
>
|
|
||||||
<div className={`dashboard-erholung-grid ${dashboardTileGridClassName(DASHBOARD_TILE_GRID_COLS.mobile)}`}>
|
|
||||||
<DashboardTile spanMobile={1} spanDesktop={2}>
|
|
||||||
<SleepWidget/>
|
|
||||||
</DashboardTile>
|
|
||||||
<DashboardTile spanMobile={1} spanDesktop={2}>
|
|
||||||
<RestDaysWidget/>
|
|
||||||
</DashboardTile>
|
|
||||||
</div>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
{activities.length > 0 && (
|
|
||||||
<DashboardSection
|
|
||||||
title="Training"
|
|
||||||
description="Verteilung der Trainingstypen (28 Tage)."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={()=>nav('/activity')}>
|
|
||||||
Details →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<TrainingTypeDistribution days={28} />
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="Ziele & Fokus"
|
|
||||||
description="Strategische Ziele und Schwerpunkte – eigener Menüpunkt „Ziele“, Kontext für KI und Dashboard."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }}
|
|
||||||
onClick={(e)=>{ e.stopPropagation(); nav('/goals') }}>
|
|
||||||
Ziele bearbeiten →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap" style={{ cursor: 'pointer' }} onClick={()=>nav('/goals')}>
|
|
||||||
{goalsCount != null && (
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text1)', marginBottom: 8 }}>
|
|
||||||
{goalsCount === 0
|
|
||||||
? 'Noch keine Ziele angelegt.'
|
|
||||||
: `${goalsCount} ${goalsCount === 1 ? 'Ziel' : 'Ziele'} im System.`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: 12, color: 'var(--text2)', padding: goalsCount != null ? '0 0 8px' : '8px 0' }}>
|
|
||||||
Hier pflegst du Focus Areas, Meilensteine und Fortschritt – unabhängig von der KI-Analyse-Seite.
|
|
||||||
Tippen zum Öffnen oder unten in der Navigation <strong>Ziele</strong> wählen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
|
|
||||||
<DashboardSection
|
|
||||||
title="KI-Auswertung"
|
|
||||||
description="Mehrstufige Pipeline und letzte Zusammenfassung."
|
|
||||||
headerRight={
|
|
||||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 10px' }}
|
|
||||||
onClick={()=>nav('/analysis')}>
|
|
||||||
<Brain size={11}/> Analysen →
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<DashboardTile>
|
|
||||||
<div className="card section-gap">
|
|
||||||
<button type="button" className="btn btn-primary btn-full" style={{marginBottom:10}}
|
|
||||||
onClick={runPipeline} disabled={pipelineLoading}>
|
|
||||||
{pipelineLoading
|
|
||||||
? <><div className="spinner" style={{width:13,height:13}}/> Analyse läuft… (3 Stufen)</>
|
|
||||||
: <><Brain size={13}/> 🔬 Mehrstufige Analyse starten</>}
|
|
||||||
</button>
|
|
||||||
{pipelineError && <div style={{fontSize:12,color:'#D85A30',marginBottom:8}}>{pipelineError}</div>}
|
|
||||||
|
|
||||||
{latestInsight ? (
|
|
||||||
<>
|
|
||||||
<div style={{fontSize:11,color:'var(--text3)',marginBottom:6}}>
|
|
||||||
Letzte Analyse: {dayjs(latestInsight.created).format('DD. MMMM YYYY, HH:mm')}
|
|
||||||
</div>
|
|
||||||
<div style={{maxHeight: showInsight?'none':120, overflow:'hidden', position:'relative'}}>
|
|
||||||
<Markdown text={latestInsight.content}/>
|
|
||||||
{!showInsight && (
|
|
||||||
<div style={{position:'absolute',bottom:0,left:0,right:0,height:40,
|
|
||||||
background:'linear-gradient(transparent,var(--surface))'}}/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button" style={{background:'none',border:'none',cursor:'pointer',
|
|
||||||
fontSize:12,color:'var(--accent)',marginTop:6,padding:0}}
|
|
||||||
onClick={()=>setShowInsight(s=>!s)}>
|
|
||||||
{showInsight?'▲ Weniger anzeigen':'▼ Vollständig anzeigen'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{fontSize:13,color:'var(--text3)',padding:'8px 0'}}>
|
|
||||||
Noch keine KI-Auswertung vorhanden.
|
|
||||||
<button type="button" className="btn btn-primary" style={{marginTop:8,display:'block',fontSize:12}}
|
|
||||||
onClick={()=>nav('/analysis')}>
|
|
||||||
Erste Analyse erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DashboardTile>
|
|
||||||
</DashboardSection>
|
|
||||||
</>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
657
frontend/src/pages/DashboardConfigurePage.jsx
Normal file
657
frontend/src/pages/DashboardConfigurePage.jsx
Normal file
|
|
@ -0,0 +1,657 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { ChevronDown, ChevronUp, GripVertical, LayoutDashboard, Plus, Search, X } from 'lucide-react'
|
||||||
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
|
import {
|
||||||
|
BODY_CHART_DAYS_DEFAULT,
|
||||||
|
BODY_CHART_DAYS_MAX,
|
||||||
|
BODY_CHART_DAYS_MIN,
|
||||||
|
normalizeBodyChartDays,
|
||||||
|
} from '../widgetSystem/bodyChartDays'
|
||||||
|
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||||
|
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||||
|
import {
|
||||||
|
moveWidget,
|
||||||
|
moveWidgetToIndex,
|
||||||
|
normalizeLayoutForEditor,
|
||||||
|
toggleWidget,
|
||||||
|
} from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
|
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||||
|
'body_overview',
|
||||||
|
'activity_overview',
|
||||||
|
'nutrition_detail_charts',
|
||||||
|
'recovery_charts_panel',
|
||||||
|
])
|
||||||
|
|
||||||
|
const DESKTOP_DND_MQ = '(min-width: 768px)'
|
||||||
|
|
||||||
|
function catalogMetaById(catalog) {
|
||||||
|
if (!catalog?.widgets?.length) return {}
|
||||||
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ adminMode?: boolean }} [props]
|
||||||
|
*/
|
||||||
|
export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
||||||
|
ensurePilotLabWidgetsRegistered()
|
||||||
|
|
||||||
|
const [bundle, setBundle] = useState(null)
|
||||||
|
const [adminFromDatabase, setAdminFromDatabase] = useState(null)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [layout, setLayout] = useState(null)
|
||||||
|
const [addPanelOpen, setAddPanelOpen] = useState(false)
|
||||||
|
const [pickerSearch, setPickerSearch] = useState('')
|
||||||
|
const [viewportDesktop, setViewportDesktop] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia(DESKTOP_DND_MQ).matches : false
|
||||||
|
)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
||||||
|
const [dragOverFullIndex, setDragOverFullIndex] = useState(null)
|
||||||
|
|
||||||
|
const pickerSearchRef = useRef(null)
|
||||||
|
|
||||||
|
const dndEnabled = viewportDesktop
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(DESKTOP_DND_MQ)
|
||||||
|
const fn = () => setViewportDesktop(mq.matches)
|
||||||
|
fn()
|
||||||
|
mq.addEventListener('change', fn)
|
||||||
|
return () => mq.removeEventListener('change', fn)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!addPanelOpen) return
|
||||||
|
const t = window.setTimeout(() => pickerSearchRef.current?.focus(), 50)
|
||||||
|
const prev = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
const onKey = (e) => {
|
||||||
|
if (e.key === 'Escape') setAddPanelOpen(false)
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(t)
|
||||||
|
document.body.style.overflow = prev
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [addPanelOpen])
|
||||||
|
|
||||||
|
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
|
||||||
|
|
||||||
|
const isWidgetCatalogAllowed = useCallback(
|
||||||
|
(widgetId) => {
|
||||||
|
const m = metaById[widgetId]
|
||||||
|
if (m == null) return true
|
||||||
|
return m.allowed !== false
|
||||||
|
},
|
||||||
|
[metaById],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||||
|
const clamped = normalizeBodyChartDays(
|
||||||
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...baseLayout,
|
||||||
|
widgets: baseLayout.widgets.map((x) =>
|
||||||
|
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
if (adminMode) {
|
||||||
|
const [cat, d] = await Promise.all([
|
||||||
|
api.adminGetWidgetsCatalogFull(),
|
||||||
|
api.adminGetDashboardProductDefault(),
|
||||||
|
])
|
||||||
|
setCatalog(cat)
|
||||||
|
setBundle({ custom: false, product_default_layout: d.layout })
|
||||||
|
setAdminFromDatabase(!!d.from_database)
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(structuredClone(d.layout)))
|
||||||
|
} else {
|
||||||
|
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||||
|
setCatalog(cat)
|
||||||
|
setBundle(b)
|
||||||
|
setAdminFromDatabase(null)
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
const base = b.custom ? b.layout : structuredClone(b.product_default_layout)
|
||||||
|
setLayout(normalizeLayoutForEditor(base))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
}
|
||||||
|
}, [adminMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const openAddPanel = () => {
|
||||||
|
setPickerSearch('')
|
||||||
|
setAddPanelOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!layout) return
|
||||||
|
let toSave = layout
|
||||||
|
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
|
||||||
|
if (draftEntries.length) {
|
||||||
|
for (const [wid, val] of draftEntries) {
|
||||||
|
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
|
||||||
|
}
|
||||||
|
setLayout(toSave)
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMsg(null)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
if (adminMode) {
|
||||||
|
await api.adminPutDashboardProductDefault(toSave)
|
||||||
|
setMsg('System-Standard für die Übersicht wurde gespeichert.')
|
||||||
|
} else {
|
||||||
|
await api.putAppDashboardLayout(toSave)
|
||||||
|
setMsg('Dein Dashboard wurde gespeichert.')
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToSystem = async () => {
|
||||||
|
const ok = adminMode
|
||||||
|
? window.confirm(
|
||||||
|
'Eintrag in der Datenbank löschen und Layout aus dem Code (widget_catalog) wiederherstellen?'
|
||||||
|
)
|
||||||
|
: window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')
|
||||||
|
if (!ok) return
|
||||||
|
setBusy(true)
|
||||||
|
setMsg(null)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
if (adminMode) {
|
||||||
|
const r = await api.adminDeleteDashboardProductDefault()
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(r.layout))
|
||||||
|
setMsg('Code-Standard wiederhergestellt (kein DB-Override mehr).')
|
||||||
|
} else {
|
||||||
|
const r = await api.resetAppDashboardLayout()
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(r.layout))
|
||||||
|
setMsg('Auf System-Standard zurückgesetzt.')
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickerLower = pickerSearch.trim().toLowerCase()
|
||||||
|
|
||||||
|
const libraryIndices = useMemo(() => {
|
||||||
|
if (!layout?.widgets) return []
|
||||||
|
return layout.widgets
|
||||||
|
.map((w, i) => i)
|
||||||
|
.filter((i) => {
|
||||||
|
const w = layout.widgets[i]
|
||||||
|
if (w.enabled || !isWidgetCatalogAllowed(w.id)) return false
|
||||||
|
if (!pickerLower) return true
|
||||||
|
const m = metaById[w.id]
|
||||||
|
const hay = `${m?.title || ''} ${m?.description || ''} ${w.id}`.toLowerCase()
|
||||||
|
return hay.includes(pickerLower)
|
||||||
|
})
|
||||||
|
}, [layout, pickerLower, metaById, isWidgetCatalogAllowed])
|
||||||
|
|
||||||
|
const activeIndices = useMemo(() => {
|
||||||
|
if (!layout?.widgets) return []
|
||||||
|
return layout.widgets
|
||||||
|
.map((w, i) => i)
|
||||||
|
.filter((i) => layout.widgets[i].enabled && isWidgetCatalogAllowed(layout.widgets[i].id))
|
||||||
|
}, [layout, isWidgetCatalogAllowed])
|
||||||
|
|
||||||
|
const addableCount = useMemo(() => {
|
||||||
|
if (!layout?.widgets) return 0
|
||||||
|
return layout.widgets.filter((w) => !w.enabled && isWidgetCatalogAllowed(w.id)).length
|
||||||
|
}, [layout, isWidgetCatalogAllowed])
|
||||||
|
|
||||||
|
const onDragStartRow = (e, fullIndex) => {
|
||||||
|
if (!dndEnabled) return
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', String(fullIndex))
|
||||||
|
try {
|
||||||
|
e.dataTransfer.setDragImage(e.currentTarget, 0, 0)
|
||||||
|
} catch {
|
||||||
|
/* ok */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragOverRow = (e, fullIndex) => {
|
||||||
|
if (!dndEnabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'move'
|
||||||
|
setDragOverFullIndex(fullIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragLeaveRow = () => {
|
||||||
|
setDragOverFullIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDropRow = (e, dropFullIndex) => {
|
||||||
|
if (!dndEnabled) return
|
||||||
|
e.preventDefault()
|
||||||
|
setDragOverFullIndex(null)
|
||||||
|
const raw = e.dataTransfer.getData('text/plain')
|
||||||
|
const from = Number.parseInt(raw, 10)
|
||||||
|
if (!Number.isFinite(from)) return
|
||||||
|
setLayout((L) => normalizeLayoutForEditor(moveWidgetToIndex(L, from, dropFullIndex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDragEndRow = () => {
|
||||||
|
setDragOverFullIndex(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err && !layout) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
||||||
|
<p style={{ color: '#D85A30' }}>{err}</p>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={load}>
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 720, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Link
|
||||||
|
to={adminMode ? '/admin/g/system' : '/settings'}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
{adminMode ? '← Basiseinstellungen (Admin)' : '← Einstellungen'}
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<LayoutDashboard size={26} color="var(--accent)" />
|
||||||
|
{adminMode ? 'Produkt-Übersicht: Systemstandard' : 'Übersicht anpassen'}
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
{adminMode
|
||||||
|
? 'Globales Standard-Dashboard für alle Nutzer ohne eigenes Layout. Gespeichert in der Datenbank; mit „Code-Standard wiederherstellen“ wird der Eintrag entfernt und der Fallback aus dem Code genutzt.'
|
||||||
|
: 'Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt im eigenen Fenster, ohne langes Scrollen.'}
|
||||||
|
</p>
|
||||||
|
{adminMode && adminFromDatabase != null && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
{adminFromDatabase ? (
|
||||||
|
<>
|
||||||
|
Aktuell gilt ein <strong>gespeicherter Systemstandard</strong> (Datenbank).
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Es liegt <strong>kein DB-Override</strong> vor — es wird der Code-Standard aus dem Widget-Katalog
|
||||||
|
verwendet.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!adminMode && !bundle?.custom && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
Du bearbeitest gerade das <strong>System-Standardlayout</strong>. Mit „Speichern“ legst du deine persönliche
|
||||||
|
Version ab.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="card-title" style={{ fontSize: 15, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span>
|
||||||
|
Aktive Kacheln · {activeIndices.length}
|
||||||
|
{dndEnabled && (
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 400, color: 'var(--text3)', marginLeft: 8 }}>
|
||||||
|
(ab Desktop: ziehen oder Pfeile)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-primary" style={{ fontSize: 13 }} onClick={openAddPanel} disabled={addableCount === 0}>
|
||||||
|
<Plus size={16} style={{ marginRight: 6, verticalAlign: 'middle' }} />
|
||||||
|
Kachel hinzufügen{addableCount > 0 ? ` (${addableCount})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{addableCount === 0 && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, marginBottom: 0 }}>
|
||||||
|
Alle freigeschalteten Kacheln sind aktiv.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginTop: 12, marginBottom: 0 }}>{err}</p>}
|
||||||
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 12, marginBottom: 0 }}>{msg}</p>}
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '12px 0 0' }}>
|
||||||
|
{activeIndices.map((i) => {
|
||||||
|
const w = layout.widgets[i]
|
||||||
|
const label = metaById[w.id]?.title || w.id
|
||||||
|
const chartDaysVal =
|
||||||
|
w.config?.chart_days != null
|
||||||
|
? normalizeBodyChartDays(w.config.chart_days)
|
||||||
|
: BODY_CHART_DAYS_DEFAULT
|
||||||
|
const dragOver = dragOverFullIndex === i
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={w.id}
|
||||||
|
onDragOver={(e) => onDragOverRow(e, i)}
|
||||||
|
onDragLeave={onDragLeaveRow}
|
||||||
|
onDrop={(e) => onDropRow(e, i)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
background: dragOver ? 'var(--surface2)' : undefined,
|
||||||
|
borderRadius: dragOver ? 8 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{dndEnabled && (
|
||||||
|
<span
|
||||||
|
draggable
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${label} verschieben`}
|
||||||
|
style={{ cursor: 'grab', color: 'var(--text3)', display: 'flex', touchAction: 'none' }}
|
||||||
|
onDragStart={(e) => onDragStartRow(e, i)}
|
||||||
|
onDragEnd={onDragEndRow}
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 160px' }}>
|
||||||
|
<input type="checkbox" checked={w.enabled} onChange={() => setLayout((L) => toggleWidget(L, i))} />
|
||||||
|
<span style={{ fontSize: 14 }}>{label}</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px' }}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||||||
|
>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px' }}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||||||
|
>
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{w.id === 'quick_capture' && (
|
||||||
|
<QuickCaptureConfigEditor
|
||||||
|
config={w.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => {
|
||||||
|
if (j !== i) return x
|
||||||
|
const cfg = { ...(x.config || {}) }
|
||||||
|
for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
|
||||||
|
delete cfg[k]
|
||||||
|
}
|
||||||
|
Object.assign(cfg, next)
|
||||||
|
return { ...x, config: cfg }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{w.id === 'kpi_board' && (
|
||||||
|
<KpiBoardConfigEditor
|
||||||
|
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
|
||||||
|
onChange={(next) =>
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => {
|
||||||
|
if (j !== i) return x
|
||||||
|
const cfg = { ...(x.config || {}) }
|
||||||
|
if (next === undefined) {
|
||||||
|
delete cfg.tiles
|
||||||
|
} else {
|
||||||
|
cfg.tiles = next
|
||||||
|
}
|
||||||
|
return { ...x, config: cfg }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
||||||
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||||
|
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: 120 }}
|
||||||
|
value={
|
||||||
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||||
|
? chartDaysDraftByWidgetId[w.id]
|
||||||
|
: String(chartDaysVal)
|
||||||
|
}
|
||||||
|
onFocus={() =>
|
||||||
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[w.id]: String(chartDaysVal),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[w.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
|
||||||
|
)
|
||||||
|
setChartDaysDraftByWidgetId((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[w.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={resetToSystem}>
|
||||||
|
{adminMode ? 'Code-Standard wiederherstellen' : 'System-Standard wiederherstellen'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to={adminMode ? '/admin' : '/'}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ textDecoration: 'none', textAlign: 'center' }}
|
||||||
|
>
|
||||||
|
{adminMode ? 'Admin-Übersicht' : 'Zur Übersicht'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addPanelOpen && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 300,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: viewportDesktop ? 'center' : 'flex-end',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: viewportDesktop ? 16 : 0,
|
||||||
|
paddingBottom: viewportDesktop ? 16 : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Schließen"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
background: 'rgba(0,0,0,0.45)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => setAddPanelOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="dashboard-add-widget-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: viewportDesktop ? 520 : '100%',
|
||||||
|
maxHeight: viewportDesktop ? 'min(85vh, 640px)' : 'min(92vh, 100%)',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: viewportDesktop ? 12 : '16px 16px 0 0',
|
||||||
|
boxShadow: viewportDesktop ? '0 12px 40px rgba(0,0,0,0.2)' : '0 -4px 24px rgba(0,0,0,0.12)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 id="dashboard-add-widget-title" className="card-title" style={{ fontSize: 16, margin: 0 }}>
|
||||||
|
Kachel hinzufügen
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '8px 10px' }}
|
||||||
|
aria-label="Schließen"
|
||||||
|
onClick={() => setAddPanelOpen(false)}
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '12px 16px', flexShrink: 0, borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Search size={18} color="var(--text3)" />
|
||||||
|
<input
|
||||||
|
ref={pickerSearchRef}
|
||||||
|
type="search"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Suchen nach Titel, Beschreibung, ID …"
|
||||||
|
value={pickerSearch}
|
||||||
|
onChange={(e) => setPickerSearch(e.target.value)}
|
||||||
|
aria-label="Widgets durchsuchen"
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', flex: 1, padding: '8px 16px 16px' }}>
|
||||||
|
{libraryIndices.length === 0 ? (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text3)', margin: 12 }}>
|
||||||
|
{pickerLower ? 'Keine Treffer.' : 'Keine weiteren Kacheln verfügbar.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{libraryIndices.map((i) => {
|
||||||
|
const w = layout.widgets[i]
|
||||||
|
const m = metaById[w.id]
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={w.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
padding: '12px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{m?.title || w.id}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 2 }}>{m?.description || ''}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{ flexShrink: 0, fontSize: 12, padding: '8px 14px' }}
|
||||||
|
onClick={() => {
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => (j === i ? { ...x, enabled: true } : x)),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
399
frontend/src/pages/DashboardLabPage.jsx
Normal file
399
frontend/src/pages/DashboardLabPage.jsx
Normal file
|
|
@ -0,0 +1,399 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api, formatFastApiDetail } from '../utils/api'
|
||||||
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
|
import {
|
||||||
|
BODY_CHART_DAYS_DEFAULT,
|
||||||
|
BODY_CHART_DAYS_MAX,
|
||||||
|
BODY_CHART_DAYS_MIN,
|
||||||
|
normalizeBodyChartDays,
|
||||||
|
} from '../widgetSystem/bodyChartDays'
|
||||||
|
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||||
|
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||||
|
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
|
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||||
|
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||||
|
'body_overview',
|
||||||
|
'activity_overview',
|
||||||
|
'nutrition_detail_charts',
|
||||||
|
'recovery_charts_panel',
|
||||||
|
])
|
||||||
|
|
||||||
|
function catalogMetaById(catalog) {
|
||||||
|
if (!catalog?.widgets?.length) return {}
|
||||||
|
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardLabPage() {
|
||||||
|
ensurePilotLabWidgetsRegistered()
|
||||||
|
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
|
const [catalog, setCatalog] = useState(null)
|
||||||
|
const [bundle, setBundle] = useState(null)
|
||||||
|
const [layout, setLayout] = useState(null)
|
||||||
|
const [err, setErr] = useState(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
/** Pro Widget-ID: Rohstring während der Eingabe (Tippen ohne sofortiges Clampen) */
|
||||||
|
const [chartDaysDraftByWidgetId, setChartDaysDraftByWidgetId] = useState({})
|
||||||
|
|
||||||
|
const metaById = catalogMetaById(catalog)
|
||||||
|
|
||||||
|
const isWidgetCatalogAllowed = useCallback(
|
||||||
|
(widgetId) => {
|
||||||
|
const m = metaById[widgetId]
|
||||||
|
if (m == null) return true
|
||||||
|
return m.allowed !== false
|
||||||
|
},
|
||||||
|
[metaById],
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleEditorIndices = useMemo(
|
||||||
|
() =>
|
||||||
|
layout?.widgets?.map((_, i) => i).filter((i) => isWidgetCatalogAllowed(layout.widgets[i].id)) ?? [],
|
||||||
|
[layout, isWidgetCatalogAllowed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const layoutForPreview = useMemo(
|
||||||
|
() =>
|
||||||
|
layout
|
||||||
|
? {
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map((w) => ({
|
||||||
|
...w,
|
||||||
|
enabled: w.enabled && isWidgetCatalogAllowed(w.id),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
[layout, isWidgetCatalogAllowed],
|
||||||
|
)
|
||||||
|
|
||||||
|
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||||
|
const clamped = normalizeBodyChartDays(
|
||||||
|
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
...baseLayout,
|
||||||
|
widgets: baseLayout.widgets.map((x) =>
|
||||||
|
x.id !== widgetId ? x : { ...x, config: { ...x.config, chart_days: clamped } }
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()])
|
||||||
|
setCatalog(cat)
|
||||||
|
setBundle(b)
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(b.layout))
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!layout) return
|
||||||
|
let toSave = layout
|
||||||
|
const draftEntries = Object.entries(chartDaysDraftByWidgetId)
|
||||||
|
if (draftEntries.length) {
|
||||||
|
for (const [wid, val] of draftEntries) {
|
||||||
|
toSave = normalizeLayoutForEditor(commitChartDaysDraftToLayout(val, toSave, wid))
|
||||||
|
}
|
||||||
|
setLayout(toSave)
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
}
|
||||||
|
setBusy(true)
|
||||||
|
setMsg(null)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
await api.putAppDashboardLayout(toSave)
|
||||||
|
setMsg('Layout gespeichert.')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = async () => {
|
||||||
|
if (!confirm('Persönliches Layout löschen und Standard wiederherstellen?')) return
|
||||||
|
setBusy(true)
|
||||||
|
setMsg(null)
|
||||||
|
setErr(null)
|
||||||
|
try {
|
||||||
|
const r = await api.resetAppDashboardLayout()
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(r.layout))
|
||||||
|
setMsg('Auf Standard zurückgesetzt.')
|
||||||
|
await load()
|
||||||
|
} catch (e) {
|
||||||
|
setErr(formatFastApiDetail(null, e.message))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDefaultLocal = () => {
|
||||||
|
if (bundle?.lab_default_layout) {
|
||||||
|
setChartDaysDraftByWidgetId({})
|
||||||
|
setLayout(normalizeLayoutForEditor(structuredClone(bundle.lab_default_layout)))
|
||||||
|
setMsg('Lab-Standard geladen (noch nicht gespeichert).')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err && !layout) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 640, margin: '0 auto' }}>
|
||||||
|
<p style={{ color: '#D85A30' }}>{err}</p>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={load}>
|
||||||
|
Erneut laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
← Einstellungen
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<LayoutGrid size={26} color="var(--accent)" />
|
||||||
|
App-Bereich: Dashboard-Lab
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Widget-System: Katalog, Registry, Renderer; optional pro Widget <code>config</code> (z. B.{' '}
|
||||||
|
<strong>Körper</strong> / <strong>Aktivität</strong>: Zeitraum 7–90 Tage; <strong>KPI</strong>: Kacheln
|
||||||
|
wählen & sortieren). Layout pro Profil in der DB —
|
||||||
|
getrennt vom Produktiv-Dashboard.
|
||||||
|
Vergleich:{' '}
|
||||||
|
<Link to="/pilot/viz" style={{ color: 'var(--accent)' }}>
|
||||||
|
Pilot-Übersicht (festes Standard-Layout)
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 20,
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderColor: 'var(--border2)',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="card-title" style={{ fontSize: 14 }}>
|
||||||
|
Layout (v1)
|
||||||
|
</div>
|
||||||
|
{bundle && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12 }}>
|
||||||
|
Status: {bundle.custom ? 'individuell gespeichert' : 'Standard (nicht in DB)'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||||
|
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||||
|
{visibleEditorIndices.map((i) => {
|
||||||
|
const w = layout.widgets[i]
|
||||||
|
const label = metaById[w.id]?.title || w.id
|
||||||
|
const chartDaysVal =
|
||||||
|
w.config?.chart_days != null
|
||||||
|
? normalizeBodyChartDays(w.config.chart_days)
|
||||||
|
: BODY_CHART_DAYS_DEFAULT
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={w.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 8, flex: '1 1 140px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={w.enabled}
|
||||||
|
onChange={() => setLayout((L) => toggleWidget(L, i))}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 14 }}>{label}</span>
|
||||||
|
</label>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px' }}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
onClick={() => setLayout((L) => moveWidget(L, i, -1))}
|
||||||
|
>
|
||||||
|
<ChevronUp size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px' }}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
onClick={() => setLayout((L) => moveWidget(L, i, 1))}
|
||||||
|
>
|
||||||
|
<ChevronDown size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{w.id === 'quick_capture' && (
|
||||||
|
<QuickCaptureConfigEditor
|
||||||
|
config={w.config || {}}
|
||||||
|
onChange={(next) =>
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => {
|
||||||
|
if (j !== i) return x
|
||||||
|
const cfg = { ...(x.config || {}) }
|
||||||
|
for (const k of ['show_weight', 'show_resting_hr', 'show_hrv', 'show_vo2_max']) {
|
||||||
|
delete cfg[k]
|
||||||
|
}
|
||||||
|
Object.assign(cfg, next)
|
||||||
|
return { ...x, config: cfg }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{w.id === 'kpi_board' && (
|
||||||
|
<KpiBoardConfigEditor
|
||||||
|
tiles={Object.prototype.hasOwnProperty.call(w.config || {}, 'tiles') ? w.config.tiles : undefined}
|
||||||
|
onChange={(next) =>
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor({
|
||||||
|
...L,
|
||||||
|
widgets: L.widgets.map((x, j) => {
|
||||||
|
if (j !== i) return x
|
||||||
|
const cfg = { ...(x.config || {}) }
|
||||||
|
if (next === undefined) {
|
||||||
|
delete cfg.tiles
|
||||||
|
} else {
|
||||||
|
cfg.tiles = next
|
||||||
|
}
|
||||||
|
return { ...x, config: cfg }
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{CHART_DAYS_WIDGET_IDS.has(w.id) && (
|
||||||
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
|
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||||
|
{w.id === 'body_overview'
|
||||||
|
? 'Körper-Chart'
|
||||||
|
: w.id === 'activity_overview'
|
||||||
|
? 'Aktivität (Verteilung & Konsistenz)'
|
||||||
|
: w.id === 'nutrition_detail_charts'
|
||||||
|
? 'Ernährung — Charts'
|
||||||
|
: 'Erholung — Charts'}{' '}
|
||||||
|
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
className="form-input"
|
||||||
|
style={{ maxWidth: 120 }}
|
||||||
|
aria-label={
|
||||||
|
w.id === 'body_overview'
|
||||||
|
? 'Körper-Chart Zeitraum in Tagen'
|
||||||
|
: w.id === 'activity_overview'
|
||||||
|
? 'Aktivität Zeitraum in Tagen'
|
||||||
|
: w.id === 'nutrition_detail_charts'
|
||||||
|
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||||
|
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||||
|
}
|
||||||
|
value={
|
||||||
|
chartDaysDraftByWidgetId[w.id] !== undefined
|
||||||
|
? chartDaysDraftByWidgetId[w.id]
|
||||||
|
: String(chartDaysVal)
|
||||||
|
}
|
||||||
|
onFocus={() =>
|
||||||
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[w.id]: String(chartDaysVal),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setChartDaysDraftByWidgetId((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[w.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const raw = e.target.value
|
||||||
|
setLayout((L) =>
|
||||||
|
normalizeLayoutForEditor(commitChartDaysDraftToLayout(raw, L, w.id))
|
||||||
|
)
|
||||||
|
setChartDaysDraftByWidgetId((prev) => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[w.id]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') e.currentTarget.blur()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={busy} onClick={save}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={reset}>
|
||||||
|
Zurücksetzen (DB)
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={busy} onClick={applyDefaultLocal}>
|
||||||
|
Standard in Editor laden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{layoutForPreview && (
|
||||||
|
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -972,7 +972,7 @@ export default function History() {
|
||||||
|
|
||||||
const loadAll = () => Promise.all([
|
const loadAll = () => Promise.all([
|
||||||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||||||
api.listNutrition(90), api.listActivity(200),
|
api.listNutrition(90), api.listActivity(25_000),
|
||||||
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
|
||||||
api.listPrompts(),
|
api.listPrompts(),
|
||||||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
||||||
|
|
@ -983,7 +983,9 @@ export default function History() {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(()=>{ loadAll() },[])
|
useEffect(() => {
|
||||||
|
loadAll()
|
||||||
|
}, [activeProfile?.quality_filter_level])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = location.state?.tab
|
const t = location.state?.tab
|
||||||
|
|
|
||||||
45
frontend/src/pages/PilotVizPage.jsx
Normal file
45
frontend/src/pages/PilotVizPage.jsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { FlaskConical } from 'lucide-react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||||
|
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||||
|
import { DEFAULT_LAB_LAYOUT } from '../widgetSystem/defaultLabLayout'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pilot-Übersicht nach Product-Spec (festes Standard-Layout).
|
||||||
|
* Nutzt dasselbe Widget-Rendering wie /app/dashboard-lab.
|
||||||
|
*/
|
||||||
|
export default function PilotVizPage() {
|
||||||
|
ensurePilotLabWidgetsRegistered()
|
||||||
|
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0)
|
||||||
|
const requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 920, margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: 20 }}>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
|
||||||
|
>
|
||||||
|
← Einstellungen
|
||||||
|
</Link>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<FlaskConical size={26} color="var(--accent)" />
|
||||||
|
Pilot: Übersicht
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von
|
||||||
|
Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WidgetRenderer
|
||||||
|
layout={DEFAULT_LAB_LAYOUT}
|
||||||
|
refreshTick={refreshTick}
|
||||||
|
requestRefresh={requestRefresh}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
716
frontend/src/pages/ProfileReferenceValuesPage.jsx
Normal file
716
frontend/src/pages/ProfileReferenceValuesPage.jsx
Normal file
|
|
@ -0,0 +1,716 @@
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
Gauge,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Minus,
|
||||||
|
ArrowLeftRight,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import {
|
||||||
|
labelSource,
|
||||||
|
labelMethod,
|
||||||
|
labelConfidence,
|
||||||
|
VALUE_DATA_TYPE_LABELS,
|
||||||
|
sortConfidenceKeys,
|
||||||
|
} from '../utils/referenceValueMeta'
|
||||||
|
|
||||||
|
const DEFAULT_FORM_META = {
|
||||||
|
source: 'manual_user',
|
||||||
|
method: 'direct_measurement',
|
||||||
|
confidence: 'medium',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryValue(row) {
|
||||||
|
if (row.value_numeric != null && row.value_numeric !== '') {
|
||||||
|
const n = Number(row.value_numeric)
|
||||||
|
return Number.isFinite(n) ? String(n) : String(row.value_numeric)
|
||||||
|
}
|
||||||
|
return row.value_text != null ? String(row.value_text) : '–'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValuePayload(selectedType, rawStr) {
|
||||||
|
const vdt = (selectedType?.value_data_type || 'decimal').toLowerCase()
|
||||||
|
const s = String(rawStr ?? '').trim()
|
||||||
|
if (vdt === 'integer' || vdt === 'decimal' || vdt === 'percentage') {
|
||||||
|
if (!s) {
|
||||||
|
return { error: 'Bitte einen Wert eingeben.', value_numeric: null, value_text: null }
|
||||||
|
}
|
||||||
|
const n = Number(s.replace(',', '.'))
|
||||||
|
if (Number.isNaN(n)) {
|
||||||
|
return { error: 'Bitte eine gültige Zahl eingeben.', value_numeric: null, value_text: null }
|
||||||
|
}
|
||||||
|
if (vdt === 'integer' && Math.abs(n - Math.round(n)) > 1e-9) {
|
||||||
|
return { error: 'Bitte eine ganze Zahl eingeben.', value_numeric: null, value_text: null }
|
||||||
|
}
|
||||||
|
return { error: null, value_numeric: n, value_text: null }
|
||||||
|
}
|
||||||
|
if (vdt === 'text' || vdt === 'enum') {
|
||||||
|
return { error: null, value_numeric: null, value_text: s }
|
||||||
|
}
|
||||||
|
return { error: null, value_numeric: null, value_text: s }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumericDelta(d, vdt) {
|
||||||
|
const v = (vdt || 'decimal').toLowerCase()
|
||||||
|
const sign = d > 0 ? '+' : '−'
|
||||||
|
let abs = Math.abs(d)
|
||||||
|
if (v === 'integer') {
|
||||||
|
return `${sign}${Math.round(abs)}`
|
||||||
|
}
|
||||||
|
let s = abs.toFixed(3)
|
||||||
|
s = s.replace(/(\.\d*?[1-9])0+$/, '$1').replace(/\.$/, '')
|
||||||
|
return `${sign}${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tendenz: jüngster vs. vorheriger Eintrag (gleiche Sortierung wie API). */
|
||||||
|
function computeRefValueTrend(valueDataType, latest, previous) {
|
||||||
|
if (!latest || !previous) {
|
||||||
|
return { variant: 'none', label: null, Icon: null }
|
||||||
|
}
|
||||||
|
const vdt = (valueDataType || 'decimal').toLowerCase()
|
||||||
|
const numericTypes = ['integer', 'decimal', 'percentage']
|
||||||
|
if (numericTypes.includes(vdt)) {
|
||||||
|
const a = latest.value_numeric != null ? Number(latest.value_numeric) : NaN
|
||||||
|
const b = previous.value_numeric != null ? Number(previous.value_numeric) : NaN
|
||||||
|
if (!Number.isFinite(a) || !Number.isFinite(b)) {
|
||||||
|
return { variant: 'unknown', label: 'Tendenz n/v', Icon: Minus }
|
||||||
|
}
|
||||||
|
const d = a - b
|
||||||
|
if (Math.abs(d) < 1e-9) {
|
||||||
|
return { variant: 'flat', label: 'gleich', Icon: Minus }
|
||||||
|
}
|
||||||
|
if (d > 0) {
|
||||||
|
return { variant: 'up', label: formatNumericDelta(d, vdt), Icon: TrendingUp }
|
||||||
|
}
|
||||||
|
return { variant: 'down', label: formatNumericDelta(d, vdt), Icon: TrendingDown }
|
||||||
|
}
|
||||||
|
const sa = formatEntryValue(latest)
|
||||||
|
const sb = formatEntryValue(previous)
|
||||||
|
if (sa === sb) {
|
||||||
|
return { variant: 'flat', label: 'unverändert', Icon: Minus }
|
||||||
|
}
|
||||||
|
return { variant: 'changed', label: 'geändert', Icon: ArrowLeftRight }
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendAccent(variant) {
|
||||||
|
if (variant === 'up') return 'var(--accent)'
|
||||||
|
if (variant === 'down') return 'var(--danger)'
|
||||||
|
if (variant === 'changed') return '#B45309'
|
||||||
|
return 'var(--text3)'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileReferenceValuesPage() {
|
||||||
|
const [types, setTypes] = useState([])
|
||||||
|
const [metaEnums, setMetaEnums] = useState({ sources: [], methods: [], confidence_levels: [] })
|
||||||
|
const [selectedKey, setSelectedKey] = useState('')
|
||||||
|
const [entries, setEntries] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [listLoading, setListLoading] = useState(false)
|
||||||
|
const [summaryTiles, setSummaryTiles] = useState([])
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [editingId, setEditingId] = useState(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
|
value: '',
|
||||||
|
notes: '',
|
||||||
|
...DEFAULT_FORM_META,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedType = types.find((t) => t.key === selectedKey)
|
||||||
|
|
||||||
|
const loadTypes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [data, enums, summaryRes] = await Promise.all([
|
||||||
|
api.listReferenceValueTypes(),
|
||||||
|
api.listReferenceValueMetaEnums(),
|
||||||
|
api.listProfileReferenceValuesSummary().catch(() => ({ tiles: [] })),
|
||||||
|
])
|
||||||
|
setTypes(Array.isArray(data) ? data : [])
|
||||||
|
setSummaryTiles(Array.isArray(summaryRes?.tiles) ? summaryRes.tiles : [])
|
||||||
|
setMetaEnums(
|
||||||
|
enums && typeof enums === 'object'
|
||||||
|
? enums
|
||||||
|
: { sources: [], methods: [], confidence_levels: [] },
|
||||||
|
)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Typen konnten nicht geladen werden')
|
||||||
|
setTypes([])
|
||||||
|
setSummaryTiles([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadSummaryOnly = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const summaryRes = await api.listProfileReferenceValuesSummary()
|
||||||
|
setSummaryTiles(Array.isArray(summaryRes?.tiles) ? summaryRes.tiles : [])
|
||||||
|
} catch {
|
||||||
|
setSummaryTiles([])
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTypes()
|
||||||
|
}, [loadTypes])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (types.length && !selectedKey) {
|
||||||
|
setSelectedKey(types[0].key)
|
||||||
|
}
|
||||||
|
}, [types, selectedKey])
|
||||||
|
|
||||||
|
const loadEntries = useCallback(async () => {
|
||||||
|
if (!selectedKey) return
|
||||||
|
try {
|
||||||
|
setListLoading(true)
|
||||||
|
const data = await api.listProfileReferenceValues(selectedKey)
|
||||||
|
setEntries(Array.isArray(data) ? data : [])
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || 'Einträge konnten nicht geladen werden')
|
||||||
|
setEntries([])
|
||||||
|
} finally {
|
||||||
|
setListLoading(false)
|
||||||
|
}
|
||||||
|
}, [selectedKey])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditingId(null)
|
||||||
|
loadEntries()
|
||||||
|
}, [loadEntries])
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setEditingId(null)
|
||||||
|
setForm({
|
||||||
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
|
value: '',
|
||||||
|
notes: '',
|
||||||
|
...DEFAULT_FORM_META,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = selectedType?.validation_rules && typeof selectedType.validation_rules === 'object'
|
||||||
|
? selectedType.validation_rules
|
||||||
|
: {}
|
||||||
|
const allowedEnum = Array.isArray(rules.allowed_values)
|
||||||
|
? rules.allowed_values.map((x) => String(x).trim()).filter(Boolean)
|
||||||
|
: []
|
||||||
|
const textMaxLen = rules.max_length != null ? parseInt(String(rules.max_length), 10) : null
|
||||||
|
const vdt = (selectedType?.value_data_type || 'decimal').toLowerCase()
|
||||||
|
|
||||||
|
const renderValueField = () => {
|
||||||
|
if (!selectedType) return null
|
||||||
|
if (vdt === 'enum') {
|
||||||
|
if (!allowedEnum.length) {
|
||||||
|
return (
|
||||||
|
<p style={{ fontSize: 13, color: '#B45309', margin: 0 }}>
|
||||||
|
Für diesen Typ sind keine ENUM-Werte konfiguriert. Bitte einen Administrator informieren.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
id="ref-value"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">— bitte wählen —</option>
|
||||||
|
{allowedEnum.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (vdt === 'integer') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id="ref-value"
|
||||||
|
type="number"
|
||||||
|
step={1}
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
|
||||||
|
placeholder="Ganze Zahl"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (vdt === 'decimal' || vdt === 'percentage') {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id="ref-value"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
|
||||||
|
placeholder={vdt === 'percentage' ? 'z. B. 72.5' : 'Zahl'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
id="ref-value"
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
required={!!rules.not_empty}
|
||||||
|
maxLength={Number.isFinite(textMaxLen) && textMaxLen > 0 ? textMaxLen : undefined}
|
||||||
|
value={form.value}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, value: e.target.value }))}
|
||||||
|
placeholder="Freitext"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!selectedKey || !selectedType) return
|
||||||
|
|
||||||
|
const built = buildValuePayload(selectedType, form.value)
|
||||||
|
if (built.error) {
|
||||||
|
setError(built.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(vdt === 'text' || vdt === 'enum') &&
|
||||||
|
rules.not_empty &&
|
||||||
|
!(built.value_text && String(built.value_text).trim())
|
||||||
|
) {
|
||||||
|
setError('Bitte einen Text eingeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
vdt === 'text' &&
|
||||||
|
Number.isFinite(textMaxLen) &&
|
||||||
|
textMaxLen > 0 &&
|
||||||
|
built.value_text &&
|
||||||
|
built.value_text.length > textMaxLen
|
||||||
|
) {
|
||||||
|
setError(`Text zu lang (max. ${textMaxLen} Ze).`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
const payload = {
|
||||||
|
reference_value_type_key: selectedKey,
|
||||||
|
effective_date: form.effective_date,
|
||||||
|
value_numeric: built.value_numeric,
|
||||||
|
value_text: built.value_text,
|
||||||
|
source: form.source,
|
||||||
|
method: form.method,
|
||||||
|
confidence: form.confidence,
|
||||||
|
notes: form.notes.trim() || null,
|
||||||
|
}
|
||||||
|
if (editingId) {
|
||||||
|
await api.updateProfileReferenceValue(editingId, {
|
||||||
|
effective_date: payload.effective_date,
|
||||||
|
value_numeric: payload.value_numeric,
|
||||||
|
value_text: payload.value_text,
|
||||||
|
source: payload.source,
|
||||||
|
method: payload.method,
|
||||||
|
confidence: payload.confidence,
|
||||||
|
notes: payload.notes,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await api.createProfileReferenceValue(payload)
|
||||||
|
}
|
||||||
|
resetForm()
|
||||||
|
await Promise.all([loadEntries(), loadSummaryOnly()])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Speichern fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = (row) => {
|
||||||
|
setEditingId(row.id)
|
||||||
|
setForm({
|
||||||
|
effective_date: String(row.effective_date || '').slice(0, 10),
|
||||||
|
value: formatEntryValue(row),
|
||||||
|
notes: row.notes || '',
|
||||||
|
source: row.source || DEFAULT_FORM_META.source,
|
||||||
|
method: row.method || DEFAULT_FORM_META.method,
|
||||||
|
confidence: row.confidence || DEFAULT_FORM_META.confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
if (!confirm('Diesen Eintrag wirklich löschen?')) return
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
await api.deleteProfileReferenceValue(id)
|
||||||
|
if (editingId === id) resetForm()
|
||||||
|
await Promise.all([loadEntries(), loadSummaryOnly()])
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Löschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ paddingBottom: 88, textAlign: 'left' }}>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<Gauge size={26} color="var(--accent)" />
|
||||||
|
Referenzwerte
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 14, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
|
||||||
|
Persönliche Kennwerte für das aktive Profil – historisch gespeichert. Der Datentyp und die Plausibilität
|
||||||
|
werden vom Administrator festgelegt; die Einheit ist fest und nicht änderbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
background: '#FCEBEB',
|
||||||
|
color: '#991B1B',
|
||||||
|
fontSize: 14,
|
||||||
|
border: '1px solid #FECACA',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{types.length === 0 ? (
|
||||||
|
<div className="card" style={{ textAlign: 'center', padding: 32 }}>
|
||||||
|
<p style={{ color: 'var(--text2)', margin: 0 }}>Keine Referenztypen definiert.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summaryTiles.filter((t) => t?.latest).length > 0 && (
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Aktuelle Werte</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 4, marginBottom: 14, lineHeight: 1.5 }}>
|
||||||
|
Übersicht aller Kennwerte mit gespeicherten Einträgen – Kachel antippen, um den Typ unten zu wählen.
|
||||||
|
Tendenz bezieht sich auf den Vergleich mit dem vorherigen Eintrag.
|
||||||
|
</p>
|
||||||
|
<div className="ref-value-tiles-grid">
|
||||||
|
{summaryTiles
|
||||||
|
.filter((t) => t?.latest)
|
||||||
|
.map((tile) => {
|
||||||
|
const latest = tile.latest
|
||||||
|
const trend = computeRefValueTrend(tile.value_data_type, latest, tile.previous)
|
||||||
|
const TrendIcon = trend.Icon
|
||||||
|
const active = tile.type_key === selectedKey
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tile.type_key}
|
||||||
|
type="button"
|
||||||
|
className={'ref-value-tile' + (active ? ' ref-value-tile--active' : '')}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedKey(tile.type_key)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
marginBottom: 6,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tile.type_label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
|
letterSpacing: '-0.02em',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatEntryValue(latest)}
|
||||||
|
{latest.unit ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
marginLeft: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{latest.unit}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>
|
||||||
|
Stand {String(latest.effective_date || '').slice(0, 10)}
|
||||||
|
</div>
|
||||||
|
{trend.variant !== 'none' && TrendIcon ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
color: trendAccent(trend.variant),
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrendIcon size={15} strokeWidth={2.25} aria-hidden />
|
||||||
|
<span>
|
||||||
|
{trend.label}
|
||||||
|
<span style={{ color: 'var(--text3)', fontWeight: 400 }}> · ggü. Vorwert</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Referenztyp</div>
|
||||||
|
<div className="settings-page__field" style={{ borderBottom: 'none', paddingTop: 0 }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-type-select">
|
||||||
|
Wähle einen Kennwert
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-type-select"
|
||||||
|
className="form-input"
|
||||||
|
value={selectedKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedKey(e.target.value)
|
||||||
|
setEditingId(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{types.map((t) => (
|
||||||
|
<option key={t.key} value={t.key}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{selectedType?.description && (
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 10, lineHeight: 1.5 }}>
|
||||||
|
{selectedType.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedType && (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>
|
||||||
|
Datentyp:{' '}
|
||||||
|
<strong>{VALUE_DATA_TYPE_LABELS[vdt] || vdt}</strong>
|
||||||
|
{selectedType.category ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Kategorie: <strong>{selectedType.category}</strong>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Plus size={16} />
|
||||||
|
{editingId ? 'Eintrag bearbeiten' : 'Neuer Eintrag'}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-date">
|
||||||
|
Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ref-date"
|
||||||
|
type="date"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.effective_date}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, effective_date: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-value">
|
||||||
|
Wert
|
||||||
|
</label>
|
||||||
|
{renderValueField()}
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<span className="settings-page__field-label">Einheit (vom Typ, nicht änderbar)</span>
|
||||||
|
<div
|
||||||
|
className="form-input"
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
cursor: 'default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedType?.default_unit?.trim() ? selectedType.default_unit : '— nicht gesetzt —'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-source">
|
||||||
|
Quelle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-source"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.source}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
|
||||||
|
>
|
||||||
|
{(metaEnums.sources || []).map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{labelSource(k)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-method">
|
||||||
|
Methode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-method"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.method}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, method: e.target.value }))}
|
||||||
|
>
|
||||||
|
{(metaEnums.methods || []).map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{labelMethod(k)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field">
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-confidence">
|
||||||
|
Vertrauensgrad
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="ref-confidence"
|
||||||
|
className="form-input"
|
||||||
|
required
|
||||||
|
value={form.confidence}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, confidence: e.target.value }))}
|
||||||
|
>
|
||||||
|
{sortConfidenceKeys(metaEnums.confidence_levels || []).map((k) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{labelConfidence(k)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="settings-page__field" style={{ borderBottom: 'none' }}>
|
||||||
|
<label className="settings-page__field-label" htmlFor="ref-notes">
|
||||||
|
Notiz (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="ref-notes"
|
||||||
|
className="form-input"
|
||||||
|
rows={3}
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
|
||||||
|
placeholder="Zusatzkontext …"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
||||||
|
<button type="submit" className="btn btn-primary btn-full" disabled={vdt === 'enum' && !allowedEnum.length}>
|
||||||
|
{editingId ? 'Speichern' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
{editingId && (
|
||||||
|
<button type="button" className="btn btn-secondary btn-full" onClick={resetForm}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title">Verlauf</div>
|
||||||
|
{listLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||||
|
<div className="spinner" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p style={{ color: 'var(--text2)', fontSize: 14, margin: 0 }}>
|
||||||
|
Noch keine Einträge für diesen Typ. Lege oben einen ersten Wert an.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ overflowX: 'auto' }}>
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)', color: 'var(--text2)' }}>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Datum</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Wert</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Einh.</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Quelle</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Methode</th>
|
||||||
|
<th style={{ padding: '8px 6px' }}>Vertr.</th>
|
||||||
|
<th style={{ padding: '8px 6px', width: 96 }} />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((row) => (
|
||||||
|
<tr key={row.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||||
|
<td style={{ padding: '10px 6px', whiteSpace: 'nowrap' }}>
|
||||||
|
{String(row.effective_date || '').slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{formatEntryValue(row)}</td>
|
||||||
|
<td style={{ padding: '10px 6px', color: 'var(--text2)' }}>{row.unit}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{labelSource(row.source)}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{labelMethod(row.method)}</td>
|
||||||
|
<td style={{ padding: '10px 6px' }}>{labelConfidence(row.confidence)}</td>
|
||||||
|
<td style={{ padding: '6px', textAlign: 'right' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', marginRight: 6 }}
|
||||||
|
title="Bearbeiten"
|
||||||
|
onClick={() => startEdit(row)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '6px 10px', color: 'var(--danger)' }}
|
||||||
|
title="Löschen"
|
||||||
|
onClick={() => handleDelete(row.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target } from 'lucide-react'
|
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, LayoutGrid, LayoutDashboard } from 'lucide-react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
import { useProfile } from '../context/ProfileContext'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card section-gap">
|
||||||
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<LayoutDashboard size={15} color="var(--accent)" /> Startseite (Übersicht)
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.6 }}>
|
||||||
|
Kacheln wählen und sortieren. Es wird nur dein persönliches Layout gespeichert – der App-Standard für neue
|
||||||
|
Nutzer wird dadurch nicht überschrieben.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/settings/dashboard-layout"
|
||||||
|
className="btn btn-primary btn-full"
|
||||||
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
|
>
|
||||||
|
Übersicht anpassen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||||
|
|
@ -441,6 +458,44 @@ export default function SettingsPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="card section-gap"
|
||||||
|
style={{ borderStyle: 'dashed', borderColor: 'var(--border2)', background: 'var(--surface2)' }}
|
||||||
|
>
|
||||||
|
<div className="card-title" style={{ fontSize: 14 }}>
|
||||||
|
Pilot: Visualisierungs-Module
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.5 }}>
|
||||||
|
Ziel-Übersicht-Pilot: Schnelleingabe, KPIs, Körper-Chart, Aktivität. Die reguläre Übersicht konfigurierst du
|
||||||
|
unter <strong>Übersicht anpassen</strong> oben.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<Link
|
||||||
|
to="/pilot/viz"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{ textAlign: 'center', textDecoration: 'none', boxSizing: 'border-box' }}
|
||||||
|
>
|
||||||
|
Pilot öffnen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/app/dashboard-lab"
|
||||||
|
className="btn btn-secondary btn-full"
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
textDecoration: 'none',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
Dashboard-Lab (Layout API)
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Auth actions */}
|
{/* Auth actions */}
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">🔐 Konto</div>
|
<div className="card-title">🔐 Konto</div>
|
||||||
|
|
|
||||||
16
frontend/src/pilot/pilotChartUtils.js
Normal file
16
frontend/src/pilot/pilotChartUtils.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
/** Gleiche Logik wie History.jsx für rollierende Mittel */
|
||||||
|
export function rollingAvg(arr, key, window = 7) {
|
||||||
|
return arr.map((d, i) => {
|
||||||
|
const s = arr
|
||||||
|
.slice(Math.max(0, i - window + 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, 0) / s.length) * 10) / 10 }
|
||||||
|
: d
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fmtDate = (d) => dayjs(d).format('DD.MM')
|
||||||
|
|
@ -5,6 +5,36 @@ export function setProfileId(id) { _profileId = id }
|
||||||
|
|
||||||
const BASE = '/api'
|
const BASE = '/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FastAPI-Fehler: `detail` kann String, Objekt oder Validierungs-Array sein.
|
||||||
|
*/
|
||||||
|
export function formatFastApiDetail(detail, fallback = '') {
|
||||||
|
if (detail == null || detail === '') {
|
||||||
|
return fallback || 'Anfrage fehlgeschlagen'
|
||||||
|
}
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
const parts = detail.map((e) => {
|
||||||
|
if (typeof e === 'string') return e
|
||||||
|
if (e && typeof e === 'object') {
|
||||||
|
const loc = Array.isArray(e.loc) ? e.loc.filter((x) => x != null && x !== '').join('.') : ''
|
||||||
|
const msg = e.msg || e.message || ''
|
||||||
|
if (loc && msg) return `${loc}: ${msg}`
|
||||||
|
return msg || loc || ''
|
||||||
|
}
|
||||||
|
return String(e)
|
||||||
|
}).filter(Boolean)
|
||||||
|
return parts.length ? parts.join(' · ') : fallback || 'Validierungsfehler'
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
if (typeof detail.msg === 'string') return detail.msg
|
||||||
|
if (typeof detail.message === 'string') return detail.message
|
||||||
|
}
|
||||||
|
return fallback || 'Anfrage fehlgeschlagen'
|
||||||
|
}
|
||||||
|
|
||||||
function hdrs(extra={}) {
|
function hdrs(extra={}) {
|
||||||
const h = {...extra}
|
const h = {...extra}
|
||||||
if (_profileId) h['X-Profile-Id'] = _profileId
|
if (_profileId) h['X-Profile-Id'] = _profileId
|
||||||
|
|
@ -16,14 +46,14 @@ function hdrs(extra={}) {
|
||||||
async function req(path, opts={}) {
|
async function req(path, opts={}) {
|
||||||
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
|
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text()
|
const errText = await res.text()
|
||||||
// Try to parse JSON error with detail field
|
let parsed = null
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(err)
|
parsed = JSON.parse(errText)
|
||||||
throw new Error(parsed.detail || err)
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(err)
|
throw new Error(errText.trim() || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
|
throw new Error(formatFastApiDetail(parsed.detail, errText.trim() || `HTTP ${res.status}`))
|
||||||
}
|
}
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +70,41 @@ export const api = {
|
||||||
getProfile: () => req('/profile'),
|
getProfile: () => req('/profile'),
|
||||||
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
updateActiveProfile:(d)=> req('/profile', jput(d)),
|
||||||
|
|
||||||
|
// App-Bereich: Dashboard-Lab (Layout JSON, Issue #65) + Widget-Katalog
|
||||||
|
getAppWidgetsCatalog: () => req('/app/widgets/catalog'),
|
||||||
|
getAppDashboardLayout: () => req('/app/dashboard-layout'),
|
||||||
|
putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)),
|
||||||
|
resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }),
|
||||||
|
|
||||||
|
adminGetWidgetsCatalogFull: () => req('/admin/widgets/catalog-full'),
|
||||||
|
adminGetDashboardProductDefault: () => req('/admin/dashboard-product-default'),
|
||||||
|
adminPutDashboardProductDefault: (layout) =>
|
||||||
|
req('/admin/dashboard-product-default', jput(layout)),
|
||||||
|
adminDeleteDashboardProductDefault: () =>
|
||||||
|
req('/admin/dashboard-product-default', { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Persönliche Referenzwerte (Profil, historisch)
|
||||||
|
listReferenceValueTypes: () => req('/reference-value-types'),
|
||||||
|
listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),
|
||||||
|
listProfileReferenceValues: (typeKey) =>
|
||||||
|
req(`/profile-reference-values?type_key=${encodeURIComponent(typeKey)}`),
|
||||||
|
listProfileReferenceValuesSummary: () => req('/profile-reference-values/summary'),
|
||||||
|
createProfileReferenceValue: (d) => req('/profile-reference-values', json(d)),
|
||||||
|
updateProfileReferenceValue: (id, d) => req(`/profile-reference-values/${id}`, jput(d)),
|
||||||
|
deleteProfileReferenceValue: (id) => req(`/profile-reference-values/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Admin: Referenzwert-Typen (Katalog)
|
||||||
|
adminListReferenceValueTypes: () => req('/admin/reference-value-types'),
|
||||||
|
adminCreateReferenceValueType: (d) => req('/admin/reference-value-types', json(d)),
|
||||||
|
adminUpdateReferenceValueType: (id, d) => req(`/admin/reference-value-types/${id}`, jput(d)),
|
||||||
|
adminDeleteReferenceValueType: (id) => req(`/admin/reference-value-types/${id}`, { method: 'DELETE' }),
|
||||||
|
adminReorderReferenceValueTypes: (orderedIds) =>
|
||||||
|
req('/admin/reference-value-types/reorder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ordered_ids: orderedIds }),
|
||||||
|
}),
|
||||||
|
|
||||||
// Weight
|
// Weight
|
||||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||||
|
|
@ -60,7 +125,12 @@ export const api = {
|
||||||
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
listActivity: (l=200)=> req(`/activity?limit=${l}`),
|
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert */
|
||||||
|
listActivity: (limit=200, days)=> {
|
||||||
|
const q = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (days != null && days !== '') q.set('days', String(days))
|
||||||
|
return req(`/activity?${q}`)
|
||||||
|
},
|
||||||
createActivity: (d) => req('/activity',json(d)),
|
createActivity: (d) => req('/activity',json(d)),
|
||||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||||
|
|
@ -70,7 +140,7 @@ export const api = {
|
||||||
importActivityCsv: async(file)=>{
|
importActivityCsv: async(file)=>{
|
||||||
const fd=new FormData();fd.append('file',file)
|
const fd=new FormData();fd.append('file',file)
|
||||||
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
const r=await fetch(`${BASE}/activity/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||||
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
|
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||||
},
|
},
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
|
|
@ -88,7 +158,7 @@ export const api = {
|
||||||
importCsv: async(file)=>{
|
importCsv: async(file)=>{
|
||||||
const fd=new FormData();fd.append('file',file)
|
const fd=new FormData();fd.append('file',file)
|
||||||
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
const r=await fetch(`${BASE}/nutrition/import-csv`,{method:'POST',body:fd,headers:hdrs()})
|
||||||
const d=await r.json();if(!r.ok)throw new Error(d.detail||JSON.stringify(d));return d
|
const d=await r.json();if(!r.ok)throw new Error(formatFastApiDetail(d.detail, JSON.stringify(d)));return d
|
||||||
},
|
},
|
||||||
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
listNutrition: (l=365) => req(`/nutrition?limit=${l}`),
|
||||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||||
|
|
@ -375,6 +445,9 @@ export const api = {
|
||||||
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
||||||
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
||||||
getFocusAreaStats: () => req('/focus-areas/stats'),
|
getFocusAreaStats: () => req('/focus-areas/stats'),
|
||||||
|
listFocusAreaUsageTypes: () => req('/focus-areas/usage-types'),
|
||||||
|
setFocusAreaUsageTypes: (id, usageTypeKeys) =>
|
||||||
|
req(`/focus-areas/definitions/${id}/usage-types`, jput({ usage_type_keys: usageTypeKeys })),
|
||||||
|
|
||||||
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
|
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
|
||||||
// Nutrition Charts (E1-E5)
|
// Nutrition Charts (E1-E5)
|
||||||
|
|
|
||||||
59
frontend/src/utils/referenceValueMeta.js
Normal file
59
frontend/src/utils/referenceValueMeta.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/** Labels für Referenzwert-Metadaten (Erfassung). */
|
||||||
|
|
||||||
|
export const REF_SOURCE_LABELS = {
|
||||||
|
manual_user: 'Manuell (Nutzer)',
|
||||||
|
manual_admin: 'Manuell (Admin)',
|
||||||
|
import_device: 'Import Gerät',
|
||||||
|
import_app: 'Import App',
|
||||||
|
derived_system: 'Abgeleitet (System)',
|
||||||
|
estimated_system: 'Geschätzt (System)',
|
||||||
|
test_entry: 'Testeintrag',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REF_METHOD_LABELS = {
|
||||||
|
direct_measurement: 'Direkte Messung',
|
||||||
|
lab_test: 'Labortest',
|
||||||
|
field_test: 'Feldtest',
|
||||||
|
questionnaire: 'Fragebogen',
|
||||||
|
formula_estimation: 'Formel-Schätzung',
|
||||||
|
trend_analysis: 'Trendanalyse',
|
||||||
|
device_algorithm: 'Geräte-Algorithmus',
|
||||||
|
manual_assessment: 'Manuelle Einschätzung',
|
||||||
|
imported_external: 'Extern importiert',
|
||||||
|
unknown: 'Unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REF_CONFIDENCE_LABELS = {
|
||||||
|
high: 'Hoch',
|
||||||
|
medium: 'Mittel',
|
||||||
|
low: 'Niedrig',
|
||||||
|
unknown: 'Unbekannt',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reihenfolge für Dropdowns (nicht alphabetisch) */
|
||||||
|
export const REF_CONFIDENCE_ORDER = ['high', 'medium', 'low', 'unknown']
|
||||||
|
|
||||||
|
export function sortConfidenceKeys(keys) {
|
||||||
|
if (!Array.isArray(keys)) return []
|
||||||
|
const known = REF_CONFIDENCE_ORDER.filter((k) => keys.includes(k))
|
||||||
|
const rest = [...keys].filter((k) => !REF_CONFIDENCE_ORDER.includes(k)).sort()
|
||||||
|
return [...known, ...rest]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function labelSource(k) {
|
||||||
|
return REF_SOURCE_LABELS[k] || k || '–'
|
||||||
|
}
|
||||||
|
export function labelMethod(k) {
|
||||||
|
return REF_METHOD_LABELS[k] || k || '–'
|
||||||
|
}
|
||||||
|
export function labelConfidence(k) {
|
||||||
|
return REF_CONFIDENCE_LABELS[k] || k || '–'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VALUE_DATA_TYPE_LABELS = {
|
||||||
|
integer: 'Ganzzahl',
|
||||||
|
decimal: 'Dezimalzahl',
|
||||||
|
percentage: 'Prozent',
|
||||||
|
text: 'Text',
|
||||||
|
enum: 'Auswahl (ENUM)',
|
||||||
|
}
|
||||||
153
frontend/src/widgetSystem/KpiBoardConfigEditor.jsx
Normal file
153
frontend/src/widgetSystem/KpiBoardConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import { KPI_KCAL_WINDOW_DEFAULT } from './bodyChartDays'
|
||||||
|
import { KPI_TILE_AVG_KCAL, KPI_TILE_BODY_FAT, REF_TILE_PREFIX } from './kpiBoardTiles'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ tiles: { id: string }[] | undefined, onChange: (next: { id: string }[] | undefined) => void }} props
|
||||||
|
* undefined tiles = automatisch (kein config.tiles)
|
||||||
|
*/
|
||||||
|
export default function KpiBoardConfigEditor({ tiles, onChange }) {
|
||||||
|
const [catalog, setCatalog] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let ok = true
|
||||||
|
api
|
||||||
|
.listProfileReferenceValuesSummary()
|
||||||
|
.then((s) => {
|
||||||
|
if (!ok) return
|
||||||
|
/** @type {{ id: string, label: string }[]} */
|
||||||
|
const opts = [
|
||||||
|
{ id: KPI_TILE_BODY_FAT, label: 'Körperfett (Caliper)' },
|
||||||
|
{ id: KPI_TILE_AVG_KCAL, label: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)` },
|
||||||
|
]
|
||||||
|
const list = Array.isArray(s?.tiles) ? s.tiles : []
|
||||||
|
for (const t of list) {
|
||||||
|
if (t?.type_key) {
|
||||||
|
opts.push({
|
||||||
|
id: `${REF_TILE_PREFIX}${t.type_key}`,
|
||||||
|
label: t.type_label || t.type_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCatalog(opts)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (ok) setCatalog([])
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const labelById = useMemo(() => Object.fromEntries(catalog.map((c) => [c.id, c.label])), [catalog])
|
||||||
|
|
||||||
|
const ordered = Array.isArray(tiles) ? tiles : []
|
||||||
|
|
||||||
|
const toggle = (id, checked) => {
|
||||||
|
if (checked) {
|
||||||
|
if (ordered.some((t) => t.id === id) || ordered.length >= 9) return
|
||||||
|
onChange([...ordered, { id }])
|
||||||
|
} else {
|
||||||
|
const next = ordered.filter((t) => t.id !== id)
|
||||||
|
onChange(next.length ? next : [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const move = (index, delta) => {
|
||||||
|
const j = index + delta
|
||||||
|
if (j < 0 || j >= ordered.length) return
|
||||||
|
const next = [...ordered]
|
||||||
|
const tmp = next[index]
|
||||||
|
next[index] = next[j]
|
||||||
|
next[j] = tmp
|
||||||
|
onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||||
|
<strong>KPI-Kacheln:</strong> wählen und sortieren (max. 9). Ohne Auswahl oder „Automatisch“ =
|
||||||
|
bisherige automatische Belegung.
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginBottom: 10, fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={() => onChange(undefined)}
|
||||||
|
>
|
||||||
|
Automatisch (wie bisher)
|
||||||
|
</button>
|
||||||
|
{ordered.length > 0 && (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
|
Reihenfolge (oben zuerst)
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{ordered.map((t, idx) => (
|
||||||
|
<li
|
||||||
|
key={`${t.id}-${idx}`}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 1 }}>{labelById[t.id] || t.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
aria-label="Nach oben"
|
||||||
|
onClick={() => move(idx, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
aria-label="Nach unten"
|
||||||
|
onClick={() => move(idx, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
maxHeight: 220,
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: 8,
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{catalog.map((c) => (
|
||||||
|
<label
|
||||||
|
key={c.id}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={ordered.some((t) => t.id === c.id)}
|
||||||
|
onChange={(e) => toggle(c.id, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{c.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx
Normal file
67
frontend/src/widgetSystem/QuickCaptureConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
/**
|
||||||
|
* Sichtbarkeit der Teile im Schnelleingabe-Widget (Dashboard-Lab).
|
||||||
|
* Default: alle sichtbar (leeres config).
|
||||||
|
*/
|
||||||
|
const KEYS = [
|
||||||
|
{ key: 'show_weight', label: 'Gewicht' },
|
||||||
|
{ key: 'show_resting_hr', label: 'Ruhepuls' },
|
||||||
|
{ key: 'show_hrv', label: 'HRV' },
|
||||||
|
{ key: 'show_vo2_max', label: 'VO₂max' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function mergeFromConfig(config) {
|
||||||
|
const c = config || {}
|
||||||
|
return {
|
||||||
|
show_weight: c.show_weight !== false,
|
||||||
|
show_resting_hr: c.show_resting_hr !== false,
|
||||||
|
show_hrv: c.show_hrv !== false,
|
||||||
|
show_vo2_max: c.show_vo2_max !== false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {{ config: Record<string, unknown>, onChange: (next: Record<string, boolean>) => void }} props */
|
||||||
|
export default function QuickCaptureConfigEditor({ config, onChange }) {
|
||||||
|
const vis = mergeFromConfig(config)
|
||||||
|
|
||||||
|
const setKey = (k, checked) => {
|
||||||
|
const next = { ...vis, [k]: checked }
|
||||||
|
if (!next.show_weight && !next.show_resting_hr && !next.show_hrv && !next.show_vo2_max) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const stored = {}
|
||||||
|
for (const { key } of KEYS) {
|
||||||
|
if (!next[key]) stored[key] = false
|
||||||
|
}
|
||||||
|
onChange(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetAllVisible = () => onChange({})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||||
|
<strong>Schnelleingabe:</strong> welche Bereiche angezeigt werden. Ohne Eintrag = alles sichtbar.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
{KEYS.map(({ key, label }) => (
|
||||||
|
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={vis[key]}
|
||||||
|
onChange={(e) => setKey(key, e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||||
|
onClick={resetAllVisible}
|
||||||
|
>
|
||||||
|
Alle einblenden (Standard)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal file
51
frontend/src/widgetSystem/WidgetErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { Component } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verhindert, dass ein fehlerhaftes Dashboard-Widget die ganze Seite mitreißt.
|
||||||
|
*/
|
||||||
|
export default class WidgetErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
const msg =
|
||||||
|
this.state.error && typeof this.state.error === 'object' && 'message' in this.state.error
|
||||||
|
? String(this.state.error.message)
|
||||||
|
: String(this.state.error)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card section-gap"
|
||||||
|
style={{ marginBottom: 16, borderColor: 'var(--danger, #D85A30)' }}
|
||||||
|
>
|
||||||
|
<div className="card-title" style={{ color: 'var(--danger, #D85A30)' }}>
|
||||||
|
Widget-Fehler
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', margin: '4px 0 8px' }}>
|
||||||
|
<code>{this.props.widgetId}</code>
|
||||||
|
</p>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
overflow: 'auto',
|
||||||
|
margin: 0,
|
||||||
|
padding: 8,
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
borderRadius: 8,
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/widgetSystem/bodyChartDays.js
Normal file
13
frontend/src/widgetSystem/bodyChartDays.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** Körper-/Aktivitäts-Chart: gültiger Bereich (sync mit backend dashboard_widget_config). */
|
||||||
|
export const BODY_CHART_DAYS_MIN = 7
|
||||||
|
export const BODY_CHART_DAYS_MAX = 90
|
||||||
|
export const BODY_CHART_DAYS_DEFAULT = 30
|
||||||
|
|
||||||
|
export function normalizeBodyChartDays(raw) {
|
||||||
|
const n = Number(raw)
|
||||||
|
if (!Number.isFinite(n)) return BODY_CHART_DAYS_DEFAULT
|
||||||
|
return Math.min(BODY_CHART_DAYS_MAX, Math.max(BODY_CHART_DAYS_MIN, Math.round(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KPI-Board Ø-Kalorien: festes Analysefenster (nicht mehr über Layout-Config). */
|
||||||
|
export const KPI_KCAL_WINDOW_DEFAULT = 7
|
||||||
83
frontend/src/widgetSystem/dashboardWidgetRegistry.jsx
Normal file
83
frontend/src/widgetSystem/dashboardWidgetRegistry.jsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import WidgetErrorBoundary from './WidgetErrorBoundary'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} LayoutWidgetEntry
|
||||||
|
* @property {string} id
|
||||||
|
* @property {boolean} enabled
|
||||||
|
* @property {Record<string, unknown>} [config]
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* @typedef {{ refreshTick: number, requestRefresh: () => void, layoutEntry: LayoutWidgetEntry }} WidgetRenderContext
|
||||||
|
*/
|
||||||
|
|
||||||
|
const registry = new Map()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: string, Component: import('react').ComponentType<any>, mapProps?: (ctx: WidgetRenderContext) => Record<string, unknown> }} spec
|
||||||
|
*/
|
||||||
|
export function registerDashboardWidget(spec) {
|
||||||
|
if (!spec?.id || !spec?.Component) {
|
||||||
|
console.warn('registerDashboardWidget: id und Component erforderlich', spec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registry.set(spec.id, spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredWidgetIds() {
|
||||||
|
return [...registry.keys()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDashboardWidgetRegistry() {
|
||||||
|
registry.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur für Tests: Registry neu füllen.
|
||||||
|
*/
|
||||||
|
export function __resetDashboardWidgetRegistryForTests() {
|
||||||
|
registry.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {WidgetRenderContext} ctx
|
||||||
|
*/
|
||||||
|
export function renderRegisteredWidget(id, ctx) {
|
||||||
|
const spec = registry.get(id)
|
||||||
|
if (!spec) {
|
||||||
|
return (
|
||||||
|
<div key={id} className="card" style={{ borderColor: 'var(--danger, #D85A30)', marginBottom: 16 }}>
|
||||||
|
<strong>Unbekanntes Widget</strong>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--text2)' }}>{id}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const { Component } = spec
|
||||||
|
const props = spec.mapProps ? spec.mapProps(ctx) : {}
|
||||||
|
return (
|
||||||
|
<WidgetErrorBoundary key={id} widgetId={id}>
|
||||||
|
<Component {...props} />
|
||||||
|
</WidgetErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert alle aktivierten Widgets in Layout-Reihenfolge.
|
||||||
|
* @param {{ version: number, widgets: Array<LayoutWidgetEntry> }} layout
|
||||||
|
* @param {{ refreshTick: number, requestRefresh: () => void }} base
|
||||||
|
*/
|
||||||
|
export function WidgetRenderer({ layout, refreshTick, requestRefresh }) {
|
||||||
|
if (!layout?.widgets?.length) return null
|
||||||
|
const enabled = layout.widgets.filter((w) => w.enabled)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{enabled.map((w) =>
|
||||||
|
renderRegisteredWidget(w.id, {
|
||||||
|
refreshTick,
|
||||||
|
requestRefresh,
|
||||||
|
layoutEntry: w,
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
frontend/src/widgetSystem/defaultLabLayout.js
Normal file
15
frontend/src/widgetSystem/defaultLabLayout.js
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* Standard-Layout v1 (nur Pilot `/pilot/viz` ohne API).
|
||||||
|
* API-Nutzer: default_layout aus Backend (alle Katalog-IDs; aktiv = DEFAULT_LAB_WIDGET_IDS).
|
||||||
|
* Diese Datei: kompakte feste 5 Widgets für den Pilot – nicht automatisch alle P1-Widgets.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LAB_LAYOUT = {
|
||||||
|
version: 1,
|
||||||
|
widgets: [
|
||||||
|
{ id: 'welcome', enabled: true },
|
||||||
|
{ id: 'quick_capture', enabled: true },
|
||||||
|
{ id: 'kpi_board', enabled: true },
|
||||||
|
{ id: 'body_overview', enabled: true },
|
||||||
|
{ id: 'activity_overview', enabled: true },
|
||||||
|
],
|
||||||
|
}
|
||||||
22
frontend/src/widgetSystem/kpiBoardTiles.js
Normal file
22
frontend/src/widgetSystem/kpiBoardTiles.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/** Feste KPI-Kachel-IDs (sync mit backend dashboard_widget_config). */
|
||||||
|
export const KPI_TILE_BODY_FAT = 'body_fat'
|
||||||
|
export const KPI_TILE_AVG_KCAL = 'avg_kcal'
|
||||||
|
|
||||||
|
export const REF_TILE_PREFIX = 'ref:'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown> | undefined} config
|
||||||
|
* @returns {string[] | undefined} undefined = automatische Kachelwahl (Legacy)
|
||||||
|
*/
|
||||||
|
export function kpiTileOrderFromConfig(config) {
|
||||||
|
if (!config || !Object.prototype.hasOwnProperty.call(config, 'tiles')) return undefined
|
||||||
|
const raw = config.tiles
|
||||||
|
if (!Array.isArray(raw)) return undefined
|
||||||
|
/** @type {string[]} */
|
||||||
|
const ids = []
|
||||||
|
for (const item of raw) {
|
||||||
|
const id = typeof item === 'string' ? item : item && item.id
|
||||||
|
if (typeof id === 'string' && id.trim()) ids.push(id.trim())
|
||||||
|
}
|
||||||
|
return ids.slice(0, 9)
|
||||||
|
}
|
||||||
41
frontend/src/widgetSystem/layoutEditor.js
Normal file
41
frontend/src/widgetSystem/layoutEditor.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
export function normalizeLayoutForEditor(layout) {
|
||||||
|
if (!layout?.widgets) return layout
|
||||||
|
return {
|
||||||
|
...layout,
|
||||||
|
widgets: layout.widgets.map((w) => ({
|
||||||
|
...w,
|
||||||
|
config: w.config && typeof w.config === 'object' ? { ...w.config } : {},
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveWidget(layout, index, delta) {
|
||||||
|
const next = [...layout.widgets]
|
||||||
|
const j = index + delta
|
||||||
|
if (j < 0 || j >= next.length) return layout
|
||||||
|
const t = next[index]
|
||||||
|
next[index] = next[j]
|
||||||
|
next[j] = t
|
||||||
|
return { ...layout, widgets: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleWidget(layout, index) {
|
||||||
|
const next = layout.widgets.map((w, i) => (i === index ? { ...w, enabled: !w.enabled } : w))
|
||||||
|
const anyOn = next.some((w) => w.enabled)
|
||||||
|
if (!anyOn) return layout
|
||||||
|
return { ...layout, widgets: next }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verschiebt eine Zeile von fromIndex nach dropIndex (Indizes im vollen layout.widgets).
|
||||||
|
* Semantik wie üblich: Element landet an Position dropIndex (nach Entfernen an der alten Stelle).
|
||||||
|
*/
|
||||||
|
export function moveWidgetToIndex(layout, fromIndex, dropIndex) {
|
||||||
|
if (!layout?.widgets?.length) return layout
|
||||||
|
if (fromIndex < 0 || fromIndex >= layout.widgets.length) return layout
|
||||||
|
if (dropIndex < 0 || dropIndex > layout.widgets.length) return layout
|
||||||
|
if (fromIndex === dropIndex) return layout
|
||||||
|
const next = [...layout.widgets]
|
||||||
|
next.splice(dropIndex, 0, next.splice(fromIndex, 1)[0])
|
||||||
|
return { ...layout, widgets: next }
|
||||||
|
}
|
||||||
148
frontend/src/widgetSystem/registerPilotLabWidgets.js
Normal file
148
frontend/src/widgetSystem/registerPilotLabWidgets.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
/**
|
||||||
|
* Pilot/Lab-Widgets registrieren. IDs müssen zu backend/widget_catalog.WIDGET_CATALOG passen.
|
||||||
|
*/
|
||||||
|
import PilotWelcome from '../components/pilot/PilotWelcome'
|
||||||
|
import PilotQuickCapture from '../components/pilot/PilotQuickCapture'
|
||||||
|
import PilotKpiBoard from '../components/pilot/PilotKpiBoard'
|
||||||
|
import PilotBodySection from '../components/pilot/PilotBodySection'
|
||||||
|
import PilotActivitySection from '../components/pilot/PilotActivitySection'
|
||||||
|
import DashboardGreetingWidget from '../components/dashboard-widgets/DashboardGreetingWidget'
|
||||||
|
import QuickWeightTodayWidget from '../components/dashboard-widgets/QuickWeightTodayWidget'
|
||||||
|
import BodyStatStripWidget from '../components/dashboard-widgets/BodyStatStripWidget'
|
||||||
|
import StatusPillsWidget from '../components/dashboard-widgets/StatusPillsWidget'
|
||||||
|
import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileGoalsProgressWidget'
|
||||||
|
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
|
||||||
|
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
||||||
|
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
||||||
|
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||||
|
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||||
|
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||||
|
import GoalsFocusTeaserWidget from '../components/dashboard-widgets/GoalsFocusTeaserWidget'
|
||||||
|
import AiPipelineInsightWidget from '../components/dashboard-widgets/AiPipelineInsightWidget'
|
||||||
|
import { normalizeBodyChartDays } from './bodyChartDays'
|
||||||
|
import { registerDashboardWidget } from './dashboardWidgetRegistry'
|
||||||
|
|
||||||
|
let _registered = false
|
||||||
|
|
||||||
|
export function ensurePilotLabWidgetsRegistered() {
|
||||||
|
if (_registered) return
|
||||||
|
_registered = true
|
||||||
|
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'welcome',
|
||||||
|
Component: PilotWelcome,
|
||||||
|
mapProps: () => ({}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'quick_capture',
|
||||||
|
Component: PilotQuickCapture,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
onSaved: ctx.requestRefresh,
|
||||||
|
captureConfig: ctx.layoutEntry?.config || {},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'kpi_board',
|
||||||
|
Component: PilotKpiBoard,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
kpiConfig: ctx.layoutEntry?.config || {},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'body_overview',
|
||||||
|
Component: PilotBodySection,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'activity_overview',
|
||||||
|
Component: PilotActivitySection,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'dashboard_greeting',
|
||||||
|
Component: DashboardGreetingWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'quick_weight_today',
|
||||||
|
Component: QuickWeightTodayWidget,
|
||||||
|
mapProps: (ctx) => ({ onSaved: ctx.requestRefresh }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'body_stat_strip',
|
||||||
|
Component: BodyStatStripWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'status_pills',
|
||||||
|
Component: StatusPillsWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'profile_goals_progress',
|
||||||
|
Component: ProfileGoalsProgressWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'trend_kcal_weight',
|
||||||
|
Component: TrendKcalWeightWidget,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'nutrition_activity_summary',
|
||||||
|
Component: NutritionActivitySummaryWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'nutrition_detail_charts',
|
||||||
|
Component: NutritionDetailChartsWidget,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'recovery_charts_panel',
|
||||||
|
Component: RecoveryChartsPanelWidget,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'progress_photos',
|
||||||
|
Component: ProgressPhotosWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'recovery_sleep_rest',
|
||||||
|
Component: RecoverySleepRestWidget,
|
||||||
|
mapProps: () => ({}),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'goals_focus_teaser',
|
||||||
|
Component: GoalsFocusTeaserWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'ai_pipeline_insight',
|
||||||
|
Component: AiPipelineInsightWidget,
|
||||||
|
mapProps: (ctx) => ({ refreshTick: ctx.refreshTick }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Nur für Tests */
|
||||||
|
export function __resetPilotLabRegistrationForTests() {
|
||||||
|
_registered = false
|
||||||
|
}
|
||||||
|
|
@ -53,10 +53,13 @@ Die Gitea-URL muss von deinem Rechner erreichbar sein (z. B. `192.168.2.144:3000
|
||||||
| `gitea_list_issues` | Issues listen, optional alle Seiten |
|
| `gitea_list_issues` | Issues listen, optional alle Seiten |
|
||||||
| `gitea_get_issue` | Ein Issue mit Body |
|
| `gitea_get_issue` | Ein Issue mit Body |
|
||||||
| `gitea_comment_issue` | Kommentar |
|
| `gitea_comment_issue` | Kommentar |
|
||||||
|
| `gitea_patch_issue` | Titel und/oder **Beschreibung** des Issues ändern (PATCH) |
|
||||||
| `gitea_create_issue` | Neu anlegen |
|
| `gitea_create_issue` | Neu anlegen |
|
||||||
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
|
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
|
||||||
| `gitea_get_repo_file` | Datei remote via API |
|
| `gitea_get_repo_file` | Datei remote via API |
|
||||||
|
|
||||||
|
**MCP vs. CLI:** Sehr lange Issue-Bodies oder Vorlagen aus Datei → `python scripts/gitea/gitea_api.py issues edit … --body-file` (siehe README). Kurze Updates direkt im Agent → `gitea_patch_issue`.
|
||||||
|
|
||||||
## Issue-Triage durch den Agent
|
## Issue-Triage durch den Agent
|
||||||
|
|
||||||
Sinnvoller Ablauf: Issues listen → je Issue **Code/Commits prüfen** → bei eindeutig erledigt: kurzer Kommentar + **close**; bei teilweise: Kommentar mit Checkboxen; bei unklar: nur Kommentar, **nicht** schließen.
|
Sinnvoller Ablauf: Issues listen → je Issue **Code/Commits prüfen** → bei eindeutig erledigt: kurzer Kommentar + **close**; bei teilweise: Kommentar mit Checkboxen; bei unklar: nur Kommentar, **nicht** schließen.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,17 @@ Dient dazu, **Issues** auf deiner Gitea-Instanz zu lesen und anzulegen – mit d
|
||||||
|
|
||||||
Python 3.10+ (nur Standardbibliothek).
|
Python 3.10+ (nur Standardbibliothek).
|
||||||
|
|
||||||
|
## MCP vs. CLI (wann was)
|
||||||
|
|
||||||
|
| Aufgabe | Empfehlung |
|
||||||
|
|--------|------------|
|
||||||
|
| Issues listen, ein Issue lesen, kurzer Kommentar, schließen/öffnen | **MCP** (`gitea_*` in Cursor), weniger Kontext im Chat |
|
||||||
|
| **Issue-Beschreibung oder Titel ändern** (PATCH) | Kurz im Chat: **MCP** `gitea_patch_issue`. Groß / aus Datei / Automation: **CLI** `issues edit --body-file` |
|
||||||
|
| Neues Issue mit langem Markdown aus Vorlage | **CLI** `issues create --body-file` |
|
||||||
|
| Remote-Datei aus Gitea lesen (nicht im Workspace) | **MCP** `gitea_get_repo_file` oder CLI `repo file` |
|
||||||
|
|
||||||
|
Beides spricht dieselbe REST-API (`gitea_lib`); Token und `GITEA_*` wie oben.
|
||||||
|
|
||||||
## Aufruf (im Repo-Root)
|
## Aufruf (im Repo-Root)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
@ -36,6 +47,11 @@ python scripts/gitea/gitea_api.py issues create --title "Fix: …" --body-file p
|
||||||
python scripts/gitea/gitea_api.py issues comment 42 --body "…"
|
python scripts/gitea/gitea_api.py issues comment 42 --body "…"
|
||||||
python scripts/gitea/gitea_api.py issues comment 42 --body-file path/to/comment.md
|
python scripts/gitea/gitea_api.py issues comment 42 --body-file path/to/comment.md
|
||||||
|
|
||||||
|
# Beschreibung und/oder Titel ändern (PATCH)
|
||||||
|
python scripts/gitea/gitea_api.py issues edit 42 --title "Neuer Titel"
|
||||||
|
python scripts/gitea/gitea_api.py issues edit 42 --body "Neuer **Markdown**-Body"
|
||||||
|
python scripts/gitea/gitea_api.py issues edit 42 --body-file path/to/body.md
|
||||||
|
|
||||||
# Schließen / wieder öffnen
|
# Schließen / wieder öffnen
|
||||||
python scripts/gitea/gitea_api.py issues close 42
|
python scripts/gitea/gitea_api.py issues close 42
|
||||||
python scripts/gitea/gitea_api.py issues reopen 42
|
python scripts/gitea/gitea_api.py issues reopen 42
|
||||||
|
|
@ -67,3 +83,4 @@ python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop
|
||||||
## MCP (Tools direkt im Agent)
|
## MCP (Tools direkt im Agent)
|
||||||
|
|
||||||
Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example).
|
Siehe [`MCP_SETUP.md`](./MCP_SETUP.md) und [`../.cursor/mcp.json.example`](../../.cursor/mcp.json.example).
|
||||||
|
Nach dem Hinzufügen neuer MCP-Tools Cursor einmal **neu starten**, damit die Tool-Liste aktualisiert wird.
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,31 @@ def cmd_issues_reopen(args: argparse.Namespace, base: str, token: str, owner: st
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_issues_edit(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
|
||||||
|
fields: dict = {}
|
||||||
|
if args.title is not None:
|
||||||
|
fields["title"] = args.title.strip()
|
||||||
|
if not fields["title"]:
|
||||||
|
sys.stderr.write("issues edit: --title darf nicht leer sein\n")
|
||||||
|
sys.exit(2)
|
||||||
|
body: str | None = None
|
||||||
|
if args.body_file:
|
||||||
|
body = Path(args.body_file).read_text(encoding="utf-8")
|
||||||
|
elif args.body is not None:
|
||||||
|
body = args.body
|
||||||
|
if body is not None:
|
||||||
|
fields["body"] = body
|
||||||
|
if not fields:
|
||||||
|
sys.stderr.write(
|
||||||
|
"issues edit: mindestens eines von --title, --body oder --body-file setzen\n"
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
status, payload = issues_patch(base, token, owner, repo, args.number, fields)
|
||||||
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
if status >= 400:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
|
def cmd_repo_contents(args: argparse.Namespace, base: str, token: str, owner: str, repo: str) -> None:
|
||||||
status, payload = repo_file_content(
|
status, payload = repo_file_content(
|
||||||
base, token, owner, repo, args.path, ref=args.ref or ""
|
base, token, owner, repo, args.path, ref=args.ref or ""
|
||||||
|
|
@ -167,6 +192,20 @@ def main() -> None:
|
||||||
p_ro.add_argument("number", type=int)
|
p_ro.add_argument("number", type=int)
|
||||||
p_ro.set_defaults(_handler=cmd_issues_reopen)
|
p_ro.set_defaults(_handler=cmd_issues_reopen)
|
||||||
|
|
||||||
|
p_ed = i_sub.add_parser(
|
||||||
|
"edit",
|
||||||
|
help="Issue per PATCH ändern (Titel und/oder Beschreibung; für große Texte --body-file)",
|
||||||
|
)
|
||||||
|
p_ed.add_argument("number", type=int)
|
||||||
|
p_ed.add_argument("--title", default=None, help="Neuer Titel")
|
||||||
|
p_ed.add_argument("--body", default=None, help="Neue Beschreibung (Markdown)")
|
||||||
|
p_ed.add_argument(
|
||||||
|
"--body-file",
|
||||||
|
default=None,
|
||||||
|
help="Beschreibung aus Datei (UTF-8); überschreibt --body wenn beides gesetzt",
|
||||||
|
)
|
||||||
|
p_ed.set_defaults(_handler=cmd_issues_edit)
|
||||||
|
|
||||||
p_repo = sub.add_parser("repo", help="Repository (API)")
|
p_repo = sub.add_parser("repo", help="Repository (API)")
|
||||||
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
|
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ mcp = FastMCP(
|
||||||
"mitai-gitea",
|
"mitai-gitea",
|
||||||
instructions=(
|
instructions=(
|
||||||
"Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. "
|
"Gitea-Tools für das Repo aus GITEA_OWNER/GITEA_REPO. "
|
||||||
"Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten."
|
"Schließe Issues nur nach klarer Code-Verifikation; sonst Kommentar mit offenen Punkten. "
|
||||||
|
"Kurze Titel-/Body-Änderungen: gitea_patch_issue. "
|
||||||
|
"Sehr lange Bodies oder Skripte: Terminal scripts/gitea/gitea_api.py issues edit … --body-file."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -95,6 +97,28 @@ def gitea_comment_issue(issue_number: int, body: str) -> str:
|
||||||
return _json({"http_status": st, "result": payload})
|
return _json({"http_status": st, "result": payload})
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def gitea_patch_issue(
|
||||||
|
issue_number: int,
|
||||||
|
title: str | None = None,
|
||||||
|
body: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Issue-Titel und/oder Beschreibung (PATCH). Mindestens eines von title/body setzen. Für sehr lange Markdown-Texte besser: CLI issues edit --body-file."""
|
||||||
|
fields: dict[str, str] = {}
|
||||||
|
if title is not None:
|
||||||
|
t = title.strip()
|
||||||
|
if not t:
|
||||||
|
return _json({"error": "title darf nicht leer sein"})
|
||||||
|
fields["title"] = t
|
||||||
|
if body is not None:
|
||||||
|
fields["body"] = body
|
||||||
|
if not fields:
|
||||||
|
return _json({"error": "Mindestens title oder body angeben"})
|
||||||
|
base, token, owner, repo = _cfg()
|
||||||
|
st, payload = issues_patch(base, token, owner, repo, issue_number, fields)
|
||||||
|
return _json({"http_status": st, "result": payload})
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def gitea_close_issue(issue_number: int) -> str:
|
def gitea_close_issue(issue_number: int) -> str:
|
||||||
"""Issue schließen (state=closed)."""
|
"""Issue schließen (state=closed)."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user