Indvidual Dashboard V0.9 #67
|
|
@ -7,6 +7,7 @@
|
|||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
|
||||
## Claude Code Verantwortlichkeiten
|
||||
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
- ✅ Bestehende Issues aktualisieren (Status, Beschreibung)
|
||||
- ✅ Issues bei Fertigstellung schließen
|
||||
- 🎯 Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo/issues
|
||||
- Gitea **MCP** vs **CLI**: kurze Lese-/Kommentar-/PATCH-Vorgänge im Agent über MCP (`gitea_*`); **Beschreibung aus Datei**, sehr lange Bodies oder Skripte → `python scripts/gitea/gitea_api.py issues edit … --body-file` — `scripts/gitea/README.md`
|
||||
|
||||
**Dokumentation:**
|
||||
- Code-Änderungen in CLAUDE.md dokumentieren
|
||||
|
|
@ -841,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
|||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||
|
||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||
> Änderungen aktualisiert.
|
||||
|
|
|
|||
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
|
||||
- correlations: Lag-analysis, plateau detection
|
||||
- utils: Shared functions (confidence, baseline, outliers)
|
||||
- training_profile: Template-based training evaluation scaffold (Layer 1)
|
||||
Import: ``from data_layer.training_profile import resolve_training_evaluation``
|
||||
- reference_values: Profile reference value reads + summary (Layer 1)
|
||||
Import: ``from data_layer import reference_values`` or submodule imports
|
||||
|
||||
Phase 0c: Multi-Layer Architecture
|
||||
Version: 1.0
|
||||
|
|
|
|||
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 workflow_questions # Phase 1 Workflow Engine - Question Catalog
|
||||
from routers import workflows # Phase 2 Workflow Engine - Execution
|
||||
from routers import reference_values # Persönliche Referenzwerte (Profil)
|
||||
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
|
||||
from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
|
||||
|
||||
# ── App Configuration ─────────────────────────────────────────────────────────
|
||||
DATA_DIR = Path(os.getenv("DATA_DIR", "./data"))
|
||||
|
|
@ -115,6 +118,9 @@ app.include_router(charts.router) # /api/charts/* (Phase 0c Charts
|
|||
# Phase 1-2 Workflow Engine
|
||||
app.include_router(workflow_questions.router) # /api/workflow/questions/* (Phase 1 Question Catalog)
|
||||
app.include_router(workflows.router) # /api/workflows/* (Phase 2 Execution)
|
||||
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
|
||||
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
|
||||
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
|
||||
|
||||
# ── Health Check ──────────────────────────────────────────────────────────────
|
||||
@app.get("/")
|
||||
|
|
|
|||
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
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth, check_feature_access, increment_feature_usage
|
||||
|
|
@ -32,24 +32,45 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
@router.get("")
|
||||
def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
|
||||
"""Get activity entries for current profile."""
|
||||
def list_activity(
|
||||
limit: int = Query(200, ge=1, le=50_000),
|
||||
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
# Issue #31: Apply global quality filter
|
||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||
profile = r2d(cur.fetchone())
|
||||
quality_filter = get_quality_filter_sql(profile)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
""", (pid, limit))
|
||||
if days is not None:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
AND date >= (CURRENT_DATE - %s::integer)
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, days, limit),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT * FROM activity_log
|
||||
WHERE profile_id=%s
|
||||
{quality_filter}
|
||||
ORDER BY date DESC, start_time DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(pid, limit),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,21 @@ import os
|
|||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_admin, hash_pin
|
||||
from models import AdminProfileUpdate
|
||||
from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict
|
||||
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
||||
from system_dashboard_product_default import (
|
||||
delete_product_default_override,
|
||||
get_product_default_base_dict,
|
||||
get_stored_product_default_validated,
|
||||
upsert_product_default_base,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
|
@ -155,3 +164,52 @@ def admin_test_email(data: dict, session: dict=Depends(require_admin)):
|
|||
return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Fehler beim Senden: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/widgets/catalog-full")
|
||||
def admin_widgets_catalog_full(session: dict = Depends(require_admin)):
|
||||
"""Dashboard-Widget-Katalog ohne Feature-Filter (Konfiguration des Produkt-Standards)."""
|
||||
_ = session
|
||||
return widgets_catalog_admin_payload()
|
||||
|
||||
|
||||
@router.get("/dashboard-product-default")
|
||||
def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
|
||||
"""Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
|
||||
_ = session
|
||||
with get_db() as conn:
|
||||
layout = get_product_default_base_dict(conn)
|
||||
from_database = get_stored_product_default_validated(conn) is not None
|
||||
code_ref = product_default_layout_dict()
|
||||
return {
|
||||
"from_database": from_database,
|
||||
"layout": layout,
|
||||
"code_reference": code_ref,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/dashboard-product-default")
|
||||
def admin_put_dashboard_product_default(
|
||||
body: dict[str, Any],
|
||||
session: dict = Depends(require_admin),
|
||||
):
|
||||
"""System-Standard persistieren (JSON wie Nutzer-Layout v1)."""
|
||||
_ = session
|
||||
try:
|
||||
payload = DashboardLayoutPayload.model_validate(body)
|
||||
except Exception as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
stored = payload.to_stored_dict()
|
||||
with get_db() as conn:
|
||||
upsert_product_default_base(conn, stored)
|
||||
return {"ok": True, "layout": stored, "from_database": True}
|
||||
|
||||
|
||||
@router.delete("/dashboard-product-default")
|
||||
def admin_delete_dashboard_product_default(session: dict = Depends(require_admin)):
|
||||
"""DB-Override entfernen; App fällt auf Code-Standard zurück."""
|
||||
_ = session
|
||||
with get_db() as conn:
|
||||
delete_product_default_override(conn)
|
||||
layout = get_product_default_base_dict(conn)
|
||||
return {"ok": True, "layout": layout, "from_database": False}
|
||||
|
|
|
|||
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
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from focus_area_usage_helpers import coerce_usage_type_keys
|
||||
|
||||
router = APIRouter(prefix="/api/focus-areas", tags=["focus-areas"])
|
||||
|
||||
|
|
@ -36,6 +37,11 @@ class UserFocusPreferences(BaseModel):
|
|||
"""User's focus area weightings (dynamic)"""
|
||||
preferences: dict # {focus_area_id: weight_pct}
|
||||
|
||||
|
||||
class FocusAreaUsageTypesUpdate(BaseModel):
|
||||
"""Replace all usage-type assignments for one focus area (admin)."""
|
||||
usage_type_keys: List[str] = Field(default_factory=list)
|
||||
|
||||
# ============================================================================
|
||||
# Focus Area Definitions (Admin)
|
||||
# ============================================================================
|
||||
|
|
@ -58,14 +64,27 @@ def list_focus_area_definitions(
|
|||
|
||||
query = """
|
||||
SELECT id, key, name_de, name_en, icon, description, category, is_active,
|
||||
created_at, updated_at
|
||||
created_at, updated_at,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_agg(faut.key ORDER BY faut.sort_order, faut.key)
|
||||
FROM focus_area_definition_usage_types fadut
|
||||
JOIN focus_area_usage_types faut ON faut.id = fadut.usage_type_id
|
||||
WHERE fadut.focus_area_id = focus_area_definitions.id
|
||||
),
|
||||
'[]'::json
|
||||
) AS allowed_usage_type_keys
|
||||
FROM focus_area_definitions
|
||||
WHERE is_active = true OR %s
|
||||
ORDER BY category, name_de
|
||||
"""
|
||||
|
||||
cur.execute(query, (include_inactive,))
|
||||
areas = [r2d(row) for row in cur.fetchall()]
|
||||
areas = []
|
||||
for row in cur.fetchall():
|
||||
d = r2d(row)
|
||||
d['allowed_usage_type_keys'] = coerce_usage_type_keys(d.get('allowed_usage_type_keys'))
|
||||
areas.append(d)
|
||||
|
||||
# Group by category
|
||||
grouped = {}
|
||||
|
|
@ -75,6 +94,10 @@ def list_focus_area_definitions(
|
|||
grouped[cat] = []
|
||||
grouped[cat].append(area)
|
||||
|
||||
if session.get('role') != 'admin':
|
||||
for area in areas:
|
||||
area.pop('allowed_usage_type_keys', None)
|
||||
|
||||
return {
|
||||
"areas": areas,
|
||||
"grouped": grouped,
|
||||
|
|
@ -226,6 +249,92 @@ def delete_focus_area_definition(
|
|||
|
||||
return {"message": "Focus Area gelöscht"}
|
||||
|
||||
|
||||
@router.get("/usage-types")
|
||||
def list_focus_area_usage_types(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
Liste aller systemdefinierten Nutzungstypen (Admin, Konfigurations-UI).
|
||||
Keine freie Anlage neuer Typen über die API.
|
||||
"""
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
SELECT id, key, label_de, sort_order
|
||||
FROM focus_area_usage_types
|
||||
ORDER BY sort_order, key
|
||||
""")
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
return {"usage_types": rows, "total": len(rows)}
|
||||
|
||||
|
||||
@router.put("/definitions/{area_id}/usage-types")
|
||||
def replace_focus_area_usage_types(
|
||||
area_id: str,
|
||||
data: FocusAreaUsageTypesUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Ersetzt die Nutzungstyp-Zuweisungen einer Focus Area (Admin).
|
||||
Leere Liste entfernt alle Zuordnungen. Unbekannte Keys → 400.
|
||||
"""
|
||||
if session.get('role') != 'admin':
|
||||
raise HTTPException(status_code=403, detail="Admin-Rechte erforderlich")
|
||||
|
||||
keys = list(dict.fromkeys(data.usage_type_keys)) # dedupe, preserve order
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
cur.execute(
|
||||
"SELECT id FROM focus_area_definitions WHERE id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="Focus Area nicht gefunden")
|
||||
|
||||
if not keys:
|
||||
cur.execute(
|
||||
"DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
return {"message": "Nutzungstyp-Zuweisungen entfernt", "usage_type_keys": []}
|
||||
|
||||
placeholders = ','.join(['%s'] * len(keys))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT id, key FROM focus_area_usage_types
|
||||
WHERE key IN ({placeholders})
|
||||
""",
|
||||
keys,
|
||||
)
|
||||
found = {row['key']: row['id'] for row in cur.fetchall()}
|
||||
missing = [k for k in keys if k not in found]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unbekannte Nutzungstyp-Keys: {', '.join(missing)}",
|
||||
)
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM focus_area_definition_usage_types WHERE focus_area_id = %s",
|
||||
(area_id,),
|
||||
)
|
||||
for k in keys:
|
||||
ut_id = found[k]
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO focus_area_definition_usage_types (focus_area_id, usage_type_id)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (focus_area_id, usage_type_id) DO NOTHING
|
||||
""",
|
||||
(area_id, ut_id),
|
||||
)
|
||||
|
||||
return {"message": "Nutzungstyp-Zuweisungen aktualisiert", "usage_type_keys": keys}
|
||||
|
||||
# ============================================================================
|
||||
# User Focus Preferences
|
||||
# ============================================================================
|
||||
|
|
|
|||
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"
|
||||
BUILD_DATE = "2026-04-05"
|
||||
DB_SCHEMA_VERSION = "20260403" # Migration 034
|
||||
DB_SCHEMA_VERSION = "20260406d" # Migration 040
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.0",
|
||||
"profiles": "1.1.0",
|
||||
"reference_values": "1.3.0",
|
||||
"admin_reference_value_types": "1.0.0",
|
||||
"weight": "1.0.3",
|
||||
"circumference": "1.0.1",
|
||||
"caliper": "1.0.1",
|
||||
"activity": "1.1.0",
|
||||
"activity": "1.2.0", # GET /activity: optional days= window + limit
|
||||
"nutrition": "1.0.2",
|
||||
"photos": "1.0.0",
|
||||
"insights": "1.3.0",
|
||||
"prompts": "1.1.0",
|
||||
"admin": "1.2.0",
|
||||
"admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full
|
||||
"stats": "1.0.1",
|
||||
"exportdata": "1.1.0",
|
||||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||
"app_dashboard": "1.10.0", # Produkt-Standard aus system_config; Response-Form unverändert
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.9n",
|
||||
"date": "2026-04-05",
|
||||
"date": "2026-04-06",
|
||||
"changes": [
|
||||
"Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)",
|
||||
"Phase 4: End Node Template Engine",
|
||||
"workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)",
|
||||
"workflow_executor.py: execute_end_node() with Jinja2 template rendering",
|
||||
|
|
|
|||
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 Analysis from './pages/Analysis'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import SettingsShell from './layouts/SettingsShell'
|
||||
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
|
||||
import PilotVizPage from './pages/PilotVizPage'
|
||||
import DashboardLabPage from './pages/DashboardLabPage'
|
||||
import DashboardConfigurePage from './pages/DashboardConfigurePage'
|
||||
import GuidePage from './pages/GuidePage'
|
||||
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
|
||||
import AdminFeaturesPage from './pages/AdminFeaturesPage'
|
||||
|
|
@ -34,6 +39,7 @@ import AdminTrainingProfiles from './pages/AdminTrainingProfiles'
|
|||
import AdminPromptsPage from './pages/AdminPromptsPage'
|
||||
import AdminGoalTypesPage from './pages/AdminGoalTypesPage'
|
||||
import AdminFocusAreasPage from './pages/AdminFocusAreasPage'
|
||||
import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage'
|
||||
import AdminHomePage from './pages/AdminHomePage'
|
||||
import AdminUsersPage from './pages/AdminUsersPage'
|
||||
import AdminSystemPage from './pages/AdminSystemPage'
|
||||
|
|
@ -224,13 +230,18 @@ function AppShell() {
|
|||
<Route path="/history" element={<History/>}/>
|
||||
<Route path="/goals" element={<GoalsPage/>}/>
|
||||
<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 path="admin" element={<AdminShell />}>
|
||||
<Route index element={<AdminHomePage />} />
|
||||
<Route path="g/:groupId" element={<AdminGroupHubPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="system" element={<AdminSystemPage />} />
|
||||
<Route path="dashboard-product-default" element={<DashboardConfigurePage adminMode />} />
|
||||
<Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
|
||||
<Route path="features" element={<AdminFeaturesPage/>}/>
|
||||
<Route path="tiers" element={<AdminTiersPage/>}/>
|
||||
|
|
@ -242,10 +253,13 @@ function AppShell() {
|
|||
<Route path="prompts" element={<AdminPromptsPage/>}/>
|
||||
<Route path="goal-types" element={<AdminGoalTypesPage/>}/>
|
||||
<Route path="focus-areas" element={<AdminFocusAreasPage/>}/>
|
||||
<Route path="reference-value-types" element={<AdminReferenceValueTypesPage/>}/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/workflow-editor/:id" element={<WorkflowEditorPage/>}/>
|
||||
<Route path="/subscription" element={<SubscriptionPage/>}/>
|
||||
<Route path="/pilot/viz" element={<PilotVizPage />} />
|
||||
<Route path="/app/dashboard-lab" element={<DashboardLabPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</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-shell {
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
const safeDays = Math.max(1, Math.min(4000, Number(days) || 28))
|
||||
const limit = Math.min(50_000, Math.max(250, safeDays * 25))
|
||||
Promise.all([
|
||||
api.listActivity(days),
|
||||
api.listActivity(limit, safeDays),
|
||||
api.getTrainingCategories()
|
||||
]).then(([activities, cats]) => {
|
||||
setCategories(cats)
|
||||
|
|
@ -43,7 +45,7 @@ export default function TrainingTypeDistribution({ days = 28 }) {
|
|||
console.error('Failed to load training type distribution:', err)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes
|
||||
}, [days, activeProfile?.quality_filter_level, activeProfile?.id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
|
|||
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',
|
||||
description: 'Dynamische Fokusbereiche und Kategorien.',
|
||||
},
|
||||
{
|
||||
to: '/admin/reference-value-types',
|
||||
label: 'Referenz-Kennwerte',
|
||||
description: 'Typen für persönliche Referenzwerte (Schlüssel, Namen, Metadaten).',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -110,6 +115,11 @@ export const ADMIN_GROUPS = [
|
|||
label: 'SMTP & Metadaten-Export',
|
||||
description: 'SMTP-Status, Test-Mail und Placeholder-Katalog (JSON/ZIP).',
|
||||
},
|
||||
{
|
||||
to: '/admin/dashboard-product-default',
|
||||
label: 'Produkt-Dashboard (Standard)',
|
||||
description: 'Globales Standard-Layout der Startseite (DB oder Code-Fallback).',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
|
|
|||
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' }
|
||||
]
|
||||
|
||||
function groupAreasByCategory(areas) {
|
||||
const grouped = {}
|
||||
for (const area of areas) {
|
||||
const cat = area.category || 'other'
|
||||
if (!grouped[cat]) grouped[cat] = []
|
||||
grouped[cat].push(area)
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
export default function AdminFocusAreasPage() {
|
||||
const [data, setData] = useState({ areas: [], grouped: {}, total: 0 })
|
||||
const [usageTypesCatalog, setUsageTypesCatalog] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showInactive, setShowInactive] = useState(false)
|
||||
|
|
@ -34,11 +45,35 @@ export default function AdminFocusAreasPage() {
|
|||
loadData()
|
||||
}, [showInactive])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const r = await api.listFocusAreaUsageTypes()
|
||||
if (!cancelled) setUsageTypesCatalog(r.usage_types || [])
|
||||
} catch (e) {
|
||||
console.error('usage-types catalog:', e)
|
||||
if (!cancelled) setUsageTypesCatalog([])
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await api.listFocusAreaDefinitions(showInactive)
|
||||
setData(result)
|
||||
const areas = (result.areas || []).map(a => ({
|
||||
...a,
|
||||
allowed_usage_type_keys: Array.isArray(a.allowed_usage_type_keys)
|
||||
? a.allowed_usage_type_keys
|
||||
: []
|
||||
}))
|
||||
setData({
|
||||
areas,
|
||||
grouped: groupAreasByCategory(areas),
|
||||
total: result.total ?? areas.length
|
||||
})
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to load focus areas:', err)
|
||||
|
|
@ -74,14 +109,20 @@ export default function AdminFocusAreasPage() {
|
|||
const handleUpdate = async (id) => {
|
||||
try {
|
||||
const area = data.areas.find(a => a.id === id)
|
||||
await api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
})
|
||||
const usageKeys = Array.isArray(area.allowed_usage_type_keys)
|
||||
? area.allowed_usage_type_keys
|
||||
: []
|
||||
await Promise.all([
|
||||
api.updateFocusAreaDefinition(id, {
|
||||
name_de: area.name_de,
|
||||
name_en: area.name_en,
|
||||
icon: area.icon,
|
||||
description: area.description,
|
||||
category: area.category,
|
||||
is_active: area.is_active
|
||||
}),
|
||||
api.setFocusAreaUsageTypes(id, usageKeys)
|
||||
])
|
||||
setEditingId(null)
|
||||
await loadData()
|
||||
} catch (err) {
|
||||
|
|
@ -113,12 +154,25 @@ export default function AdminFocusAreasPage() {
|
|||
}
|
||||
|
||||
const updateField = (id, field, value) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
areas: prev.areas.map(a =>
|
||||
setData(prev => {
|
||||
const areas = prev.areas.map(a =>
|
||||
a.id === id ? { ...a, [field]: value } : a
|
||||
)
|
||||
}))
|
||||
return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleUsageTypeKey = (areaId, key, checked) => {
|
||||
setData(prev => {
|
||||
const areas = prev.areas.map(a => {
|
||||
if (a.id !== areaId) return a
|
||||
const cur = new Set(Array.isArray(a.allowed_usage_type_keys) ? a.allowed_usage_type_keys : [])
|
||||
if (checked) cur.add(key)
|
||||
else cur.delete(key)
|
||||
return { ...a, allowed_usage_type_keys: [...cur] }
|
||||
})
|
||||
return { ...prev, areas, grouped: groupAreasByCategory(areas), total: areas.length }
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
|
|
@ -364,6 +418,45 @@ export default function AdminFocusAreasPage() {
|
|||
/>
|
||||
</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 }}>
|
||||
<button
|
||||
className="btn-primary"
|
||||
|
|
@ -430,6 +523,37 @@ export default function AdminFocusAreasPage() {
|
|||
{area.description}
|
||||
</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 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 { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Check, Brain } from 'lucide-react'
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip,
|
||||
ResponsiveContainer, CartesianGrid
|
||||
} from 'recharts'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { useProfile } from '../context/ProfileContext'
|
||||
import { getBfCategory } from '../utils/calc'
|
||||
import TrialBanner from '../components/TrialBanner'
|
||||
import EmailVerificationBanner from '../components/EmailVerificationBanner'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import SleepWidget from '../components/SleepWidget'
|
||||
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')
|
||||
import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
|
||||
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
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
|
||||
})
|
||||
function catalogMetaById(catalog) {
|
||||
if (!catalog?.widgets?.length) return {}
|
||||
return Object.fromEntries(catalog.widgets.map((w) => [w.id, w]))
|
||||
}
|
||||
|
||||
// ── 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() {
|
||||
const nav = useNavigate()
|
||||
const location = useLocation()
|
||||
const { activeProfile } = useProfile()
|
||||
|
||||
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 [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 requestRefresh = () => setRefreshTick((t) => t + 1)
|
||||
|
||||
const load = () => Promise.all([
|
||||
api.getStats(),
|
||||
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)
|
||||
})
|
||||
useEffect(() => {
|
||||
ensurePilotLabWidgetsRegistered()
|
||||
}, [])
|
||||
|
||||
const runPipeline = async () => {
|
||||
setPipelineLoading(true); setPipelineError(null)
|
||||
try {
|
||||
await api.insightPipeline()
|
||||
await load()
|
||||
} catch(e) {
|
||||
setPipelineError('Fehler: '+e.message)
|
||||
} finally { setPipelineLoading(false) }
|
||||
}
|
||||
|
||||
useEffect(()=>{ load() },[])
|
||||
useEffect(() => {
|
||||
let cancel = false
|
||||
setLayoutLoading(true)
|
||||
Promise.all([api.getAppDashboardLayout(), api.getAppWidgetsCatalog()])
|
||||
.then(([b, c]) => {
|
||||
if (cancel) return
|
||||
setLayoutBundle(b)
|
||||
setCatalog(c)
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancel) return
|
||||
setLayoutBundle(null)
|
||||
setCatalog(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancel) setLayoutLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancel = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.state?.adminDenied) return
|
||||
|
|
@ -311,60 +60,19 @@ export default function Dashboard() {
|
|||
return () => window.clearTimeout(clear)
|
||||
}, [location.state, nav])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProfile?.id) return
|
||||
api.listGoals()
|
||||
.then((list) => setGoalsCount(Array.isArray(list) ? list.length : 0))
|
||||
.catch(() => setGoalsCount(null))
|
||||
}, [activeProfile?.id])
|
||||
const metaById = useMemo(() => catalogMetaById(catalog), [catalog])
|
||||
|
||||
if (loading) return <div className="empty-state"><div className="spinner"/></div>
|
||||
|
||||
const latestCal = calipers[0]
|
||||
const latestCir = circs[0]
|
||||
const latestW = weights[0]
|
||||
const prevW = weights[1]
|
||||
const sex = activeProfile?.sex||'m'
|
||||
const height = activeProfile?.height||178
|
||||
|
||||
// Deltas
|
||||
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
|
||||
|
||||
// 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
|
||||
const layoutForPreview = useMemo(() => {
|
||||
if (!layoutBundle?.layout) return null
|
||||
const L = layoutBundle.layout
|
||||
return {
|
||||
...L,
|
||||
widgets: L.widgets.map((w) => ({
|
||||
...w,
|
||||
enabled: w.enabled && metaById[w.id]?.allowed !== false,
|
||||
})),
|
||||
}
|
||||
}, [layoutBundle, metaById])
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
|
|
@ -382,296 +90,41 @@ export default function Dashboard() {
|
|||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle.
|
||||
Du wurdest zur Übersicht weitergeleitet.
|
||||
<strong>Kein Admin-Zugriff.</strong> Dieser Bereich ist nur für Konten mit Administrator-Rolle. Du wurdest zur
|
||||
Übersicht weitergeleitet.
|
||||
</div>
|
||||
)}
|
||||
{/* Header greeting */}
|
||||
<div className="dashboard-greeting">
|
||||
<h1 style={{fontSize:22,fontWeight:800,margin:0,color:'var(--text1)'}}>
|
||||
Hallo, {activeProfile?.name||'Nutzer'} 👋
|
||||
</h1>
|
||||
<div className="dashboard-greeting__meta" style={{fontSize:12,color:'var(--text3)',marginTop:2}}>
|
||||
{dayjs().format('dddd, DD. MMMM YYYY')}
|
||||
{latestW && ` · Letztes Update ${dayjs(latestW.date).format('DD.MM.')}`}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
|
||||
<Link
|
||||
to="/settings/dashboard-layout"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
padding: '8px 12px',
|
||||
textDecoration: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<LayoutDashboard size={16} />
|
||||
Übersicht anpassen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Banner */}
|
||||
{activeProfile && <EmailVerificationBanner profile={activeProfile}/>}
|
||||
{activeProfile && <EmailVerificationBanner profile={activeProfile} />}
|
||||
{activeProfile && <TrialBanner profile={activeProfile} />}
|
||||
|
||||
{/* Trial Banner */}
|
||||
{activeProfile && <TrialBanner profile={activeProfile}/>}
|
||||
|
||||
{!hasAnyData && (
|
||||
{layoutLoading && (
|
||||
<div className="empty-state">
|
||||
<h3>Willkommen bei Mitai Jinkendo!</h3>
|
||||
<p>Starte mit deiner ersten Messung.</p>
|
||||
<button className="btn btn-primary" onClick={()=>nav('/capture')}>
|
||||
Erfassen starten
|
||||
</button>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasAnyData && <>
|
||||
<DashboardSection
|
||||
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>
|
||||
</>}
|
||||
{!layoutLoading && layoutForPreview && (
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
)}
|
||||
</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([
|
||||
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.listPrompts(),
|
||||
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
|
||||
|
|
@ -983,7 +983,9 @@ export default function History() {
|
|||
setLoading(false)
|
||||
})
|
||||
|
||||
useEffect(()=>{ loadAll() },[])
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
}, [activeProfile?.quality_filter_level])
|
||||
|
||||
useEffect(() => {
|
||||
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 { 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 { useProfile } from '../context/ProfileContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
|
@ -428,6 +428,23 @@ export default function SettingsPage() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card section-gap">
|
||||
<div className="card-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<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-title" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Target size={15} color="var(--accent)" /> Strategische Ziele
|
||||
|
|
@ -441,6 +458,44 @@ export default function SettingsPage() {
|
|||
</Link>
|
||||
</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 */}
|
||||
<div className="card section-gap">
|
||||
<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'
|
||||
|
||||
/**
|
||||
* 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={}) {
|
||||
const h = {...extra}
|
||||
if (_profileId) h['X-Profile-Id'] = _profileId
|
||||
|
|
@ -16,14 +46,14 @@ function hdrs(extra={}) {
|
|||
async function req(path, opts={}) {
|
||||
const res = await fetch(BASE+path, {...opts, headers:hdrs(opts.headers||{})})
|
||||
if (!res.ok) {
|
||||
const err = await res.text()
|
||||
// Try to parse JSON error with detail field
|
||||
const errText = await res.text()
|
||||
let parsed = null
|
||||
try {
|
||||
const parsed = JSON.parse(err)
|
||||
throw new Error(parsed.detail || err)
|
||||
parsed = JSON.parse(errText)
|
||||
} 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()
|
||||
}
|
||||
|
|
@ -40,6 +70,41 @@ export const api = {
|
|||
getProfile: () => req('/profile'),
|
||||
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
|
||||
listWeight: (l=365) => req(`/weight?limit=${l}`),
|
||||
upsertWeight: (date,weight,note='') => req('/weight',json({date,weight,note})),
|
||||
|
|
@ -60,7 +125,12 @@ export const api = {
|
|||
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
||||
|
||||
// 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)),
|
||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||
|
|
@ -70,7 +140,7 @@ export const api = {
|
|||
importActivityCsv: async(file)=>{
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
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
|
||||
|
|
@ -88,7 +158,7 @@ export const api = {
|
|||
importCsv: async(file)=>{
|
||||
const fd=new FormData();fd.append('file',file)
|
||||
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}`),
|
||||
nutritionCorrelations: () => req('/nutrition/correlations'),
|
||||
|
|
@ -375,6 +445,9 @@ export const api = {
|
|||
getUserFocusPreferences: () => req('/focus-areas/user-preferences'),
|
||||
updateUserFocusPreferences: (d) => req('/focus-areas/user-preferences', jput(d)),
|
||||
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)
|
||||
// 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_get_issue` | Ein Issue mit Body |
|
||||
| `gitea_comment_issue` | Kommentar |
|
||||
| `gitea_patch_issue` | Titel und/oder **Beschreibung** des Issues ändern (PATCH) |
|
||||
| `gitea_create_issue` | Neu anlegen |
|
||||
| `gitea_close_issue` / `gitea_reopen_issue` | Status |
|
||||
| `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
|
||||
|
||||
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).
|
||||
|
||||
## 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)
|
||||
|
||||
```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-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
|
||||
python scripts/gitea/gitea_api.py issues close 42
|
||||
python scripts/gitea/gitea_api.py issues reopen 42
|
||||
|
|
@ -66,4 +82,5 @@ python scripts/gitea/gitea_api.py repo file backend/main.py --ref develop
|
|||
|
||||
## 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)
|
||||
|
||||
|
||||
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:
|
||||
status, payload = repo_file_content(
|
||||
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.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)")
|
||||
r_sub = p_repo.add_subparsers(dest="repo_cmd", required=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,9 @@ mcp = FastMCP(
|
|||
"mitai-gitea",
|
||||
instructions=(
|
||||
"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})
|
||||
|
||||
|
||||
@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()
|
||||
def gitea_close_issue(issue_number: int) -> str:
|
||||
"""Issue schließen (state=closed)."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user