Merge pull request 'Indvidual Dashboard V0.9' (#67) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 1m0s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

Reviewed-on: #67
This commit is contained in:
Lars 2026-04-08 10:56:02 +02:00
commit 09439ad1a5
97 changed files with 9427 additions and 669 deletions

View File

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

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

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

View 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

View File

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

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

View File

@ -0,0 +1,36 @@
"""
Training profile resolver (Layer 1 scaffold).
Template-driven multi-dimensional evaluation with built-in algorithms and
Focus Area contribution aggregation. Import explicitly from this package.
Public API:
- resolve_training_evaluation
- resolve_for_base_profile
- models: CalculationTemplate, TrainingEvaluationResult, ...
- registries: templates, profiles, algorithms
"""
from data_layer.training_profile.models import (
CalculationTemplate,
DimensionResult,
DimensionSpec,
FocusAreaMapping,
TrainingBaseProfile,
TrainingEvaluationResult,
)
from data_layer.training_profile.resolver import (
resolve_for_base_profile,
resolve_training_evaluation,
)
__all__ = [
"CalculationTemplate",
"DimensionResult",
"DimensionSpec",
"FocusAreaMapping",
"TrainingBaseProfile",
"TrainingEvaluationResult",
"resolve_for_base_profile",
"resolve_training_evaluation",
]

View File

@ -0,0 +1,13 @@
"""Built-in training evaluation algorithms (code-defined only)."""
from .registry import (
get_algorithm,
list_algorithm_ids,
register_algorithm,
)
__all__ = [
"get_algorithm",
"list_algorithm_ids",
"register_algorithm",
]

View File

@ -0,0 +1,23 @@
"""
Algorithm protocol: fixed implementations selected by id from declarative templates.
"""
from __future__ import annotations
from typing import Any, Dict, Mapping, Protocol
from data_layer.training_profile.models import AlgorithmRunResult
class TrainingAlgorithm(Protocol):
"""Built-in algorithm callable shape."""
id: str
def __call__(
self,
*,
inputs: Mapping[str, Any],
params: Mapping[str, Any],
) -> AlgorithmRunResult:
...

View File

@ -0,0 +1,6 @@
"""Example built-in algorithms (threshold bands, linear range mapping)."""
from .linear_range import linear_range_score
from .threshold_band import threshold_band_score
__all__ = ["linear_range_score", "threshold_band_score"]

View File

@ -0,0 +1,69 @@
"""
linear_range: map a value linearly from [min_value, max_value] to [0, 1], clamped.
params:
value_key: str
min_value: float
max_value: float
invert: bool (optional) if True, high values map to 0
Missing value score 0 and missing_inputs.
"""
from __future__ import annotations
from typing import Any, Mapping
from data_layer.training_profile.models import AlgorithmRunResult
from data_layer.utils import safe_float
ALGORITHM_ID = "linear_range"
def linear_range_score(
*,
inputs: Mapping[str, Any],
params: Mapping[str, Any],
) -> AlgorithmRunResult:
value_key = str(params.get("value_key", ""))
if not value_key:
return AlgorithmRunResult(
raw_score=0.0,
normalized_score=0.0,
missing_inputs=["__param_value_key__"],
detail={"error": "params.value_key required"},
)
if value_key not in inputs or inputs[value_key] is None:
return AlgorithmRunResult(
raw_score=0.0,
normalized_score=0.0,
missing_inputs=[value_key],
detail={"value_key": value_key},
)
raw = safe_float(inputs[value_key])
lo = safe_float(params.get("min_value"))
hi = safe_float(params.get("max_value"))
invert = bool(params.get("invert", False))
if hi <= lo:
return AlgorithmRunResult(
raw_score=raw,
normalized_score=0.0,
missing_inputs=[],
detail={"error": "max_value must be > min_value", "min": lo, "max": hi},
)
t = (raw - lo) / (hi - lo)
t = max(0.0, min(1.0, t))
if invert:
t = 1.0 - t
return AlgorithmRunResult(
raw_score=raw,
normalized_score=t,
missing_inputs=[],
detail={"value_key": value_key, "min": lo, "max": hi, "invert": invert},
)

View File

@ -0,0 +1,75 @@
"""
threshold_band: map a numeric value into a score using ordered upper bounds.
params:
value_key: str key in inputs to read
bands: list of { "max": float | null, "score": float } ascending max; null = +inf
Missing value_key normalized_score 0, missing_inputs lists value_key.
"""
from __future__ import annotations
from typing import Any, List, Mapping
from data_layer.training_profile.models import AlgorithmRunResult
from data_layer.utils import safe_float
ALGORITHM_ID = "threshold_band"
def threshold_band_score(
*,
inputs: Mapping[str, Any],
params: Mapping[str, Any],
) -> AlgorithmRunResult:
value_key = str(params.get("value_key", ""))
if not value_key:
return AlgorithmRunResult(
raw_score=0.0,
normalized_score=0.0,
missing_inputs=["__param_value_key__"],
detail={"error": "params.value_key required"},
)
if value_key not in inputs or inputs[value_key] is None:
return AlgorithmRunResult(
raw_score=0.0,
normalized_score=0.0,
missing_inputs=[value_key],
detail={"value_key": value_key},
)
raw = safe_float(inputs[value_key])
bands = params.get("bands") or []
if not isinstance(bands, list) or not bands:
return AlgorithmRunResult(
raw_score=raw,
normalized_score=0.0,
missing_inputs=[],
detail={"error": "params.bands must be a non-empty list", "raw": raw},
)
score = 0.0
for band in bands:
if not isinstance(band, dict):
continue
max_v = band.get("max")
s = safe_float(band.get("score"))
if max_v is None:
score = s
break
if raw <= safe_float(max_v):
score = s
break
else:
score = safe_float(bands[-1].get("score")) if isinstance(bands[-1], dict) else 0.0
norm = max(0.0, min(1.0, score))
return AlgorithmRunResult(
raw_score=raw,
normalized_score=norm,
missing_inputs=[],
detail={"value_key": value_key, "bands_applied": True, "band_score": score},
)

View File

@ -0,0 +1,47 @@
"""
Registry for built-in algorithm ids callables.
Only code registers algorithms; templates reference ids declared here.
"""
from __future__ import annotations
from typing import Any, Callable, Dict, Mapping
from data_layer.training_profile.algorithms.builtin.linear_range import (
ALGORITHM_ID as LINEAR_RANGE_ID,
linear_range_score,
)
from data_layer.training_profile.algorithms.builtin.threshold_band import (
ALGORITHM_ID as THRESHOLD_BAND_ID,
threshold_band_score,
)
from data_layer.training_profile.models import AlgorithmRunResult
AlgorithmFn = Callable[..., AlgorithmRunResult]
_REGISTRY: Dict[str, AlgorithmFn] = {}
def register_algorithm(algorithm_id: str, fn: AlgorithmFn) -> None:
if algorithm_id in _REGISTRY:
raise ValueError(f"Algorithm already registered: {algorithm_id}")
_REGISTRY[algorithm_id] = fn
def get_algorithm(algorithm_id: str) -> AlgorithmFn:
if algorithm_id not in _REGISTRY:
raise KeyError(f"Unknown algorithm_id: {algorithm_id}")
return _REGISTRY[algorithm_id]
def list_algorithm_ids() -> tuple[str, ...]:
return tuple(sorted(_REGISTRY.keys()))
def _register_defaults() -> None:
register_algorithm(THRESHOLD_BAND_ID, threshold_band_score)
register_algorithm(LINEAR_RANGE_ID, linear_range_score)
_register_defaults()

View File

@ -0,0 +1,121 @@
"""
Declarative schemas for template-based training evaluation (Layer 1 scaffold).
Templates select built-in algorithms by id and pass parameters; they do not embed
executable logic. See resolver and algorithm registry.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional
@dataclass(frozen=True)
class FocusAreaMapping:
"""Maps a share of one dimension's score to a Focus Area (by stable key)."""
focus_area_key: str
weight: float
@dataclass(frozen=True)
class DimensionSpec:
"""
One evaluation dimension: algorithm + inputs + params + FA mapping.
inputs: names of keys expected in the flat activity_inputs dict passed to the resolver.
"""
key: str
algorithm_id: str
inputs: tuple[str, ...]
params: Mapping[str, Any]
maps_to: tuple[FocusAreaMapping, ...]
@dataclass(frozen=True)
class CalculationTemplate:
"""Declarative multi-dimensional calculation template (in-code registry)."""
id: str
version: str
label: str
dimensions: tuple[DimensionSpec, ...]
@dataclass(frozen=True)
class TrainingBaseProfile:
"""
Conceptual base profile: links a training context to a default template.
allowed_dimension_keys: if set, dimensions not listed are skipped at resolve time.
"""
key: str
label: str
default_template_id: str
allowed_dimension_keys: Optional[frozenset[str]] = None
@dataclass
class AlgorithmRunResult:
"""Output of a single built-in algorithm execution."""
raw_score: float
normalized_score: float
missing_inputs: List[str]
detail: Dict[str, Any] = field(default_factory=dict)
@dataclass
class DimensionResult:
"""Per-dimension result after resolution."""
dimension_key: str
algorithm_id: str
raw_score: float
normalized_score: float
missing_inputs: List[str]
evidence: Dict[str, Any] = field(default_factory=dict)
@dataclass
class TrainingEvaluationResult:
"""
Stable structured result for Layer 2 (KI, charts, APIs, future persistence).
focus_area_contributions: aggregated contribution per focus_area key (additive).
"""
template_id: str
template_version: str
base_profile_key: Optional[str]
dimension_results: List[DimensionResult]
focus_area_contributions: Dict[str, float]
confidence: str
evidence: Dict[str, Any]
trace: Optional[Dict[str, Any]] = None
def to_serializable(self) -> Dict[str, Any]:
"""JSON-compatible dict (for APIs / storage)."""
return {
"template_id": self.template_id,
"template_version": self.template_version,
"base_profile_key": self.base_profile_key,
"dimension_results": [
{
"dimension_key": d.dimension_key,
"algorithm_id": d.algorithm_id,
"raw_score": d.raw_score,
"normalized_score": d.normalized_score,
"missing_inputs": d.missing_inputs,
"evidence": d.evidence,
}
for d in self.dimension_results
],
"focus_area_contributions": dict(self.focus_area_contributions),
"confidence": self.confidence,
"evidence": self.evidence,
"trace": self.trace,
}

View File

@ -0,0 +1,13 @@
"""Training base profile registrations (scaffold, in-code)."""
from .registry import (
get_training_base_profile,
list_training_base_profile_keys,
try_get_training_base_profile,
)
__all__ = [
"get_training_base_profile",
"list_training_base_profile_keys",
"try_get_training_base_profile",
]

View File

@ -0,0 +1,52 @@
"""
Example training base profiles point to default templates and optional dimension filters.
Future: DB-backed training types may reference profile keys; this registry is code-only.
"""
from __future__ import annotations
from typing import Dict, Optional
from data_layer.training_profile.models import TrainingBaseProfile
_PROFILES: Dict[str, TrainingBaseProfile] = {}
def _register(p: TrainingBaseProfile) -> None:
if p.key in _PROFILES:
raise ValueError(f"Duplicate training base profile key: {p.key}")
_PROFILES[p.key] = p
def get_training_base_profile(key: str) -> TrainingBaseProfile:
if key not in _PROFILES:
raise KeyError(f"Unknown training base profile: {key}")
return _PROFILES[key]
def try_get_training_base_profile(key: str) -> Optional[TrainingBaseProfile]:
return _PROFILES.get(key)
def list_training_base_profile_keys() -> tuple[str, ...]:
return tuple(sorted(_PROFILES.keys()))
_register(
TrainingBaseProfile(
key="scaffold_aerobic_base",
label="Scaffold: aerobic base profile",
default_template_id="scaffold_example_aerobic_v1",
allowed_dimension_keys=None,
)
)
_register(
TrainingBaseProfile(
key="scaffold_strength_base",
label="Scaffold: strength base profile",
default_template_id="scaffold_example_strength_v1",
allowed_dimension_keys=frozenset({"effort"}),
)
)

View File

@ -0,0 +1,160 @@
"""
Layer 1 entry: resolve a multi-dimensional training evaluation from a template.
Pure calculation orchestration no DB, no HTTP, no formatting for KI/charts.
"""
from __future__ import annotations
from collections import defaultdict
from typing import Any, Dict, List, Mapping, Optional
from data_layer.training_profile.algorithms.registry import get_algorithm
from data_layer.training_profile.models import (
CalculationTemplate,
DimensionResult,
DimensionSpec,
TrainingBaseProfile,
TrainingEvaluationResult,
)
def _required_inputs_present(
activity_inputs: Mapping[str, Any], keys: tuple[str, ...]
) -> tuple[bool, List[str]]:
missing: List[str] = []
for k in keys:
if k not in activity_inputs or activity_inputs[k] is None:
missing.append(k)
return (len(missing) == 0, missing)
def _confidence_level(total_dims: int, dims_with_any_missing: int) -> str:
if total_dims == 0:
return "insufficient"
if dims_with_any_missing == 0:
return "high"
if dims_with_any_missing >= total_dims:
return "insufficient"
if dims_with_any_missing == 1:
return "medium"
return "low"
def _filter_dimensions(
template: CalculationTemplate, base_profile: Optional[TrainingBaseProfile]
) -> tuple[DimensionSpec, ...]:
if base_profile is None or base_profile.allowed_dimension_keys is None:
return template.dimensions
allowed = base_profile.allowed_dimension_keys
return tuple(d for d in template.dimensions if d.key in allowed)
def resolve_training_evaluation(
*,
activity_inputs: Mapping[str, Any],
template: CalculationTemplate,
base_profile: Optional[TrainingBaseProfile] = None,
include_trace: bool = False,
) -> TrainingEvaluationResult:
"""
Run all template dimensions, aggregate Focus Area contributions, attach evidence.
activity_inputs: flat dict (e.g. avg_hr, duration_min, distance_km) supplied by caller.
"""
dimensions = _filter_dimensions(template, base_profile)
dimension_results: List[DimensionResult] = []
contributions: Dict[str, float] = defaultdict(float)
evidence: Dict[str, Any] = {
"dimensions_total": len(dimensions),
"inputs_keys": sorted(activity_inputs.keys()),
}
trace: Optional[Dict[str, Any]] = {} if include_trace else None
dims_with_missing = 0
for spec in dimensions:
ok, missing = _required_inputs_present(activity_inputs, spec.inputs)
if not ok:
dims_with_missing += 1
dimension_results.append(
DimensionResult(
dimension_key=spec.key,
algorithm_id=spec.algorithm_id,
raw_score=0.0,
normalized_score=0.0,
missing_inputs=list(missing),
evidence={"skipped": True, "reason": "required_inputs_missing"},
)
)
if trace is not None:
trace[spec.key] = {"skipped": True, "missing": missing}
continue
algo = get_algorithm(spec.algorithm_id)
slice_inputs = {k: activity_inputs[k] for k in spec.inputs}
run = algo(inputs=slice_inputs, params=dict(spec.params))
if run.missing_inputs:
dims_with_missing += 1
dimension_results.append(
DimensionResult(
dimension_key=spec.key,
algorithm_id=spec.algorithm_id,
raw_score=run.raw_score,
normalized_score=run.normalized_score,
missing_inputs=list(run.missing_inputs),
evidence={"algorithm_detail": run.detail},
)
)
for m in spec.maps_to:
contributions[m.focus_area_key] += run.normalized_score * m.weight
if trace is not None:
trace[spec.key] = {
"inputs": dict(slice_inputs),
"params": dict(spec.params),
"run": {
"raw_score": run.raw_score,
"normalized_score": run.normalized_score,
"missing_inputs": run.missing_inputs,
"detail": run.detail,
},
"maps_to": [(x.focus_area_key, x.weight) for x in spec.maps_to],
}
conf = _confidence_level(len(dimensions), dims_with_missing)
evidence["dimensions_with_missing_or_failed"] = dims_with_missing
return TrainingEvaluationResult(
template_id=template.id,
template_version=template.version,
base_profile_key=base_profile.key if base_profile else None,
dimension_results=dimension_results,
focus_area_contributions=dict(contributions),
confidence=conf,
evidence=evidence,
trace=trace,
)
def resolve_for_base_profile(
*,
activity_inputs: Mapping[str, Any],
base_profile_key: str,
include_trace: bool = False,
) -> TrainingEvaluationResult:
"""Convenience: load profile + default template from registries."""
from data_layer.training_profile.profiles.registry import get_training_base_profile
from data_layer.training_profile.templates.registry import get_calculation_template
profile = get_training_base_profile(base_profile_key)
template = get_calculation_template(profile.default_template_id)
return resolve_training_evaluation(
activity_inputs=activity_inputs,
template=template,
base_profile=profile,
include_trace=include_trace,
)

View File

@ -0,0 +1,5 @@
"""Declarative calculation templates (in-code registry, scaffold)."""
from .registry import get_calculation_template, list_calculation_template_ids
__all__ = ["get_calculation_template", "list_calculation_template_ids"]

View File

@ -0,0 +1,96 @@
"""
Example calculation templates declarative only; algorithms are referenced by id.
These are scaffolding examples, not production coaching rules.
"""
from __future__ import annotations
from typing import Dict
from data_layer.training_profile.models import CalculationTemplate, DimensionSpec, FocusAreaMapping
_TEMPLATES: Dict[str, CalculationTemplate] = {}
def _register(t: CalculationTemplate) -> None:
if t.id in _TEMPLATES:
raise ValueError(f"Duplicate template id: {t.id}")
_TEMPLATES[t.id] = t
def get_calculation_template(template_id: str) -> CalculationTemplate:
if template_id not in _TEMPLATES:
raise KeyError(f"Unknown calculation template: {template_id}")
return _TEMPLATES[template_id]
def list_calculation_template_ids() -> tuple[str, ...]:
return tuple(sorted(_TEMPLATES.keys()))
# --- Example templates (illustrative) ---
_example_aerobic = CalculationTemplate(
id="scaffold_example_aerobic_v1",
version="1",
label="Scaffold: aerobic-style intensity + volume (example)",
dimensions=(
DimensionSpec(
key="intensity",
algorithm_id="threshold_band",
inputs=("avg_hr", "duration_min"),
params={
"value_key": "avg_hr",
"bands": [
{"max": 120, "score": 0.25},
{"max": 140, "score": 0.55},
{"max": 160, "score": 0.85},
{"max": None, "score": 1.0},
],
},
maps_to=(
FocusAreaMapping("aerobic_endurance", 0.7),
FocusAreaMapping("cardiovascular_health", 0.3),
),
),
DimensionSpec(
key="volume",
algorithm_id="linear_range",
inputs=("duration_min", "distance_km"),
params={
"value_key": "duration_min",
"min_value": 10.0,
"max_value": 90.0,
"invert": False,
},
maps_to=(FocusAreaMapping("aerobic_endurance", 0.8),),
),
),
)
_example_strength = CalculationTemplate(
id="scaffold_example_strength_v1",
version="1",
label="Scaffold: strength session load (example)",
dimensions=(
DimensionSpec(
key="effort",
algorithm_id="linear_range",
inputs=("duration_min",),
params={
"value_key": "duration_min",
"min_value": 20.0,
"max_value": 75.0,
"invert": False,
},
maps_to=(
FocusAreaMapping("strength", 0.9),
FocusAreaMapping("strength_endurance", 0.1),
),
),
),
)
_register(_example_aerobic)
_register(_example_strength)

View File

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

View File

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

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

View File

@ -0,0 +1,99 @@
-- Migration 037: Persönliche Referenzwerte (Typkatalog + historische Werte pro Profil)
-- Date: 2026-04-06
-- Purpose: System-definierte Referenztyp-Schlüssel; Nutzer pflegt nur historische Einträge.
CREATE TABLE IF NOT EXISTS reference_value_types (
id SERIAL PRIMARY KEY,
key VARCHAR(64) NOT NULL UNIQUE,
label VARCHAR(200) NOT NULL,
description TEXT,
default_unit VARCHAR(32),
sort_order INT NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE reference_value_types IS
'Systemdefinierte Typen persönlicher Referenzwerte (kein Nutzer-CRUD auf Typen)';
CREATE TABLE IF NOT EXISTS profile_reference_values (
id BIGSERIAL PRIMARY KEY,
profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
reference_value_type_id INT NOT NULL REFERENCES reference_value_types(id) ON DELETE RESTRICT,
effective_date DATE NOT NULL,
value_numeric NUMERIC(18, 6),
value_text TEXT,
unit VARCHAR(32) NOT NULL,
source TEXT,
confidence NUMERIC(5, 2),
method TEXT,
notes TEXT,
extra JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT profile_reference_values_value_ck CHECK (
value_numeric IS NOT NULL OR (value_text IS NOT NULL AND length(trim(value_text)) > 0)
)
);
COMMENT ON TABLE profile_reference_values IS
'Historische Referenzwerte pro Profil (kein Überschreiben eines Einzel-»aktuellen« Werts)';
CREATE INDEX IF NOT EXISTS idx_prv_profile_type_date
ON profile_reference_values (profile_id, reference_value_type_id, effective_date DESC);
CREATE INDEX IF NOT EXISTS idx_prv_profile
ON profile_reference_values (profile_id);
-- Seed: nur Typdefinitionen, keine Benutzerwerte
INSERT INTO reference_value_types (key, label, description, default_unit, sort_order, active) VALUES
(
'max_heart_rate',
'Maximale Herzfrequenz',
'Individuelle HRmax (z. B. aus Leistungstest oder geschätzt).',
'bpm',
10,
TRUE
),
(
'anaerobic_threshold_hr',
'Anaerober Schwellenwert (Herzfrequenz)',
'Laktatschwelle / anaerober Schwellenpuls.',
'bpm',
20,
TRUE
),
(
'aerobic_threshold_hr',
'Aerober Schwellenwert (Herzfrequenz)',
'Erster aerobet/schwellenanaloger Trainingsbereich (GA2).',
'bpm',
30,
TRUE
),
(
'training_frequency_weekly',
'Trainingshäufigkeit',
'Geplante oder beobachtete Einheiten pro Woche.',
'Sessions/Woche',
40,
TRUE
),
(
'fitness_level',
'Fitnesslevel',
'Subjektive oder normierte Einstufung (Zahl oder Kurzbeschreibung im Freitextfeld).',
'Stufe',
50,
TRUE
),
(
'resting_heart_rate',
'Ruhepuls (Referenz)',
'Ruheherzfrequenz als persönliche Referenz (z. B. morgens).',
'bpm',
15,
TRUE
)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,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
);

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

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

View 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

View File

@ -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()]

View File

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

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

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

View File

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

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

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

View 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

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

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

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

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

View 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

View File

@ -0,0 +1,138 @@
"""
Unit tests: Layer 1 training profile resolver scaffold.
No database; pure template + algorithm + resolver behavior.
"""
import pytest
from data_layer.training_profile import (
CalculationTemplate,
DimensionSpec,
FocusAreaMapping,
TrainingEvaluationResult,
resolve_for_base_profile,
resolve_training_evaluation,
)
from data_layer.training_profile.algorithms.registry import (
get_algorithm,
list_algorithm_ids,
register_algorithm,
)
from data_layer.training_profile.models import AlgorithmRunResult
from data_layer.training_profile.profiles.registry import get_training_base_profile
from data_layer.training_profile.templates.registry import get_calculation_template
class TestAlgorithmRegistry:
def test_builtin_algorithms_registered(self):
ids = list_algorithm_ids()
assert "threshold_band" in ids
assert "linear_range" in ids
def test_get_algorithm_runs_threshold(self):
fn = get_algorithm("threshold_band")
r = fn(
inputs={"avg_hr": 130.0},
params={
"value_key": "avg_hr",
"bands": [
{"max": 120, "score": 0.2},
{"max": 150, "score": 0.8},
{"max": None, "score": 1.0},
],
},
)
assert r.normalized_score == 0.8
def test_duplicate_register_raises(self):
def dummy(*, inputs, params):
return AlgorithmRunResult(0.0, 0.0, [])
with pytest.raises(ValueError, match="already registered"):
register_algorithm("threshold_band", dummy)
class TestResolver:
def test_example_template_resolves(self):
tpl = get_calculation_template("scaffold_example_aerobic_v1")
result = resolve_training_evaluation(
activity_inputs={
"avg_hr": 135.0,
"duration_min": 45.0,
"distance_km": 10.0,
},
template=tpl,
)
assert isinstance(result, TrainingEvaluationResult)
assert result.template_id == "scaffold_example_aerobic_v1"
assert result.confidence == "high"
assert "aerobic_endurance" in result.focus_area_contributions
assert len(result.dimension_results) == 2
for dr in result.dimension_results:
assert dr.missing_inputs == []
def test_missing_required_input_skips_dimension(self):
tpl = get_calculation_template("scaffold_example_aerobic_v1")
result = resolve_training_evaluation(
activity_inputs={"avg_hr": 135.0},
template=tpl,
)
assert result.confidence in ("medium", "low", "insufficient")
skipped = [d for d in result.dimension_results if d.evidence.get("skipped")]
assert len(skipped) >= 1
def test_base_profile_filters_dimensions(self):
profile = get_training_base_profile("scaffold_strength_base")
tpl = get_calculation_template(profile.default_template_id)
result = resolve_training_evaluation(
activity_inputs={"duration_min": 50.0},
template=tpl,
base_profile=profile,
)
assert len(result.dimension_results) == 1
assert result.dimension_results[0].dimension_key == "effort"
def test_resolve_for_base_profile_convenience(self):
result = resolve_for_base_profile(
activity_inputs={"duration_min": 40.0},
base_profile_key="scaffold_strength_base",
include_trace=True,
)
assert result.base_profile_key == "scaffold_strength_base"
assert result.trace is not None
assert "effort" in result.trace
def test_to_serializable(self):
tpl = get_calculation_template("scaffold_example_strength_v1")
r = resolve_training_evaluation(
activity_inputs={"duration_min": 45.0},
template=tpl,
)
d = r.to_serializable()
assert d["template_id"] == tpl.id
assert "focus_area_contributions" in d
assert isinstance(d["dimension_results"], list)
class TestCustomTemplate:
def test_unknown_algorithm_raises(self):
bad = CalculationTemplate(
id="bad",
version="1",
label="bad",
dimensions=(
DimensionSpec(
key="x",
algorithm_id="does_not_exist",
inputs=("a",),
params={},
maps_to=(FocusAreaMapping("strength", 1.0),),
),
),
)
with pytest.raises(KeyError):
resolve_training_evaluation(
activity_inputs={"a": 1.0},
template=bad,
)

View File

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

View File

@ -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
View 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 790); Feature weight_entries",
"requires_feature": "weight_entries",
},
{
"id": "activity_overview",
"title": "Aktivität",
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 790; 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 790, 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 790, Default 30); Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5",
"description": "RecoveryCharts wie Verlauf (optional chart_days 790, 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)

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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 &amp; 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>
)
}

View File

@ -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 &amp; 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,32 @@
import { useNavigate } from 'react-router-dom'
import RecoveryCharts from '../RecoveryCharts'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
/**
* Erholung R1R5 (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>
)
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

View File

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

View 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 (&gt; 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>
)
}

View File

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

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

View 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 (790), 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 790 Tage; <strong>KPI</strong>: Kacheln
wählen &amp; 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>
)
}

View File

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

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

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

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target } from 'lucide-react'
import { Save, Download, Upload, Check, LogOut, Key, BarChart3, Target, 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>

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

View File

@ -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 HEUTEdays (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)

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

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

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

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

View 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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -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)."""