Widget Update #102

Merged
Lars merged 9 commits from develop into main 2026-04-23 09:12:58 +02:00
46 changed files with 5896 additions and 2218 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
""" """
from __future__ import annotations from __future__ import annotations
import copy
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
@ -25,6 +26,7 @@ __all__ = [
"coalesce_effective_layout", "coalesce_effective_layout",
"default_layout_dict", "default_layout_dict",
"lab_default_layout_dict", "lab_default_layout_dict",
"merge_missing_catalog_widgets",
"product_default_layout_dict", "product_default_layout_dict",
] ]
@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]:
return product_default_layout_dict() return product_default_layout_dict()
def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]:
"""
Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config).
Bestehende Reihenfolge bleibt erhalten nötig, damit neue Katalog-Einträge in
Übersicht anpassen / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen.
"""
out = copy.deepcopy(layout)
widgets: list[dict[str, Any]] = list(out.get("widgets") or [])
seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")}
for e in WIDGET_CATALOG:
wid = e["id"]
if wid not in seen:
widgets.append({"id": wid, "enabled": False, "config": {}})
seen.add(wid)
out["version"] = out.get("version", 1)
out["widgets"] = widgets
return out
class DashboardWidgetEntry(BaseModel): class DashboardWidgetEntry(BaseModel):
id: str = Field(min_length=1, max_length=64) id: str = Field(min_length=1, max_length=64)
enabled: bool = True enabled: bool = True

View File

@ -14,6 +14,11 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_overview", "body_overview",
"body_history_viz",
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
"activity_overview", "activity_overview",
"kpi_board", "kpi_board",
"quick_capture", "quick_capture",
@ -32,6 +37,141 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"}) _KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$") _KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_goals_strip",
"show_intro_blurb",
"show_layer_meta",
"show_kpis",
"show_weight_chart",
"show_body_fat_chart",
"show_proportion_chart",
"show_circumference_index_chart",
"show_circumference_lines_chart",
})
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_goals_strip": False,
"show_intro_blurb": False,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_weight_chart": True,
"show_body_fat_chart": False,
"show_proportion_chart": False,
"show_circumference_index_chart": False,
"show_circumference_lines_chart": False,
}
_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_goals_strip",
"show_intro_blurb",
"show_kpis",
"show_kcal_vs_weight",
"show_calorie_balance_chart",
"show_protein_lean_chart",
"show_heuristics",
"show_macro_daily_bars",
"show_macro_distribution_pair",
"show_energy_protein_charts",
})
_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_goals_strip": False,
"show_intro_blurb": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_kcal_vs_weight": True,
"show_calorie_balance_chart": False,
"show_protein_lean_chart": False,
"show_heuristics": False,
"show_macro_daily_bars": True,
"show_macro_distribution_pair": True,
"show_energy_protein_charts": False,
}
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_layer_meta",
"show_kpis",
"show_progress_insights",
"show_chart_training_volume",
"show_chart_training_type_distribution",
"show_chart_quality_sessions",
"show_chart_load_monitoring",
})
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_progress_insights": False,
"show_chart_training_volume": True,
"show_chart_training_type_distribution": True,
"show_chart_quality_sessions": False,
"show_chart_load_monitoring": False,
}
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_layer_meta",
"show_kpis",
"show_progress_insights",
"show_sleep_section_heading",
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_heart_section_heading",
"show_heart_context_card",
"show_chart_hrv_rhr",
"show_vitals_extra_heading",
"show_vitals_extra_trends",
})
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_layer_meta": False,
"show_kpis": True,
"kpi_detail": "compact",
"show_progress_insights": False,
"show_sleep_section_heading": True,
"show_chart_recovery_score": True,
"show_chart_sleep_quality": True,
"show_chart_sleep_debt": False,
"show_heart_section_heading": True,
"show_heart_context_card": False,
"show_chart_hrv_rhr": True,
"show_vitals_extra_heading": False,
"show_vitals_extra_trends": False,
}
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
"show_section_body",
"show_section_nutrition",
"show_section_fitness",
"show_section_recovery",
})
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_confidence_banner",
"show_intro_blurb",
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
"show_correlation_c1_c3",
"show_drivers_c4",
})
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
"chart_days": 30,
"show_confidence_banner": True,
"show_intro_blurb": True,
"show_section_body": True,
"show_section_nutrition": True,
"show_section_fitness": True,
"show_section_recovery": True,
"show_correlation_c1_c3": True,
"show_drivers_c4": True,
}
def _config_json_size_bytes(config: dict[str, Any]) -> int: def _config_json_size_bytes(config: dict[str, Any]) -> int:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8")) return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
@ -39,19 +179,42 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]: def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
if raw is None: if raw is None:
return {} raw = {}
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein") raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES: 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)") 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: if widget_id not in WIDGETS_ALLOWING_CONFIG:
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt") if raw:
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
return {}
if not raw:
if widget_id == "body_history_viz":
return _validate_body_history_viz_config({})
if widget_id == "nutrition_history_viz":
return _validate_nutrition_history_viz_config({})
if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config({})
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config({})
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config({})
return {}
if widget_id == "body_overview": if widget_id == "body_overview":
return _validate_chart_days_only(raw, label="body_overview") return _validate_chart_days_only(raw, label="body_overview")
if widget_id == "body_history_viz":
return _validate_body_history_viz_config(raw)
if widget_id == "nutrition_history_viz":
return _validate_nutrition_history_viz_config(raw)
if widget_id == "fitness_history_viz":
return _validate_fitness_history_viz_config(raw)
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config(raw)
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config(raw)
if widget_id == "activity_overview": if widget_id == "activity_overview":
return _validate_chart_days_only(raw, label="activity_overview") return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board": if widget_id == "kpi_board":
@ -150,6 +313,210 @@ def _parse_chart_days(v: Any, label: str) -> int:
raise ValueError(f"{label}: chart_days muss ganze Zahl sein") raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "body_history_viz"
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
for k in _BODY_HISTORY_VIZ_BOOL_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
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
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")
out["chart_days"] = v
if not out["show_kpis"] and not any(
out[k]
for k in (
"show_weight_chart",
"show_body_fat_chart",
"show_proportion_chart",
"show_circumference_index_chart",
"show_circumference_lines_chart",
)
):
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
return out
def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "nutrition_history_viz"
allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS)
for k in _NUTRITION_HISTORY_VIZ_BOOL_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
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
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")
out["chart_days"] = v
if not out["show_kpis"] and not any(
out[k]
for k in (
"show_kcal_vs_weight",
"show_calorie_balance_chart",
"show_protein_lean_chart",
"show_heuristics",
"show_macro_daily_bars",
"show_macro_distribution_pair",
"show_energy_protein_charts",
)
):
raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein")
return out
def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "fitness_history_viz"
allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS)
for k in _FITNESS_HISTORY_VIZ_BOOL_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
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
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")
out["chart_days"] = v
if not out["show_kpis"] and not out["show_progress_insights"] and not any(
out[k]
for k in (
"show_chart_training_volume",
"show_chart_training_type_distribution",
"show_chart_quality_sessions",
"show_chart_load_monitoring",
)
):
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
return out
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "recovery_history_viz"
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
unknown = set(raw) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
for k in _RECOVERY_HISTORY_VIZ_BOOL_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
if "kpi_detail" in raw:
kd = raw["kpi_detail"]
if kd not in ("compact", "full"):
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
out["kpi_detail"] = kd
if "chart_days" in raw:
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")
out["chart_days"] = v
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
"show_vitals_extra_trends"
] and not any(
out[k]
for k in (
"show_chart_recovery_score",
"show_chart_sleep_quality",
"show_chart_sleep_debt",
"show_chart_hrv_rhr",
)
):
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
return out
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
r = dict(raw)
if "show_area_summaries" not in r:
return r
leg = r.pop("show_area_summaries")
if not isinstance(leg, bool):
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
if k not in r:
r[k] = leg
return r
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "history_overview_viz"
raw_m = _migrate_history_overview_viz_raw(raw)
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
unknown = set(raw_m) - allowed
if unknown:
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
if k not in raw_m:
continue
v = raw_m[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
if "chart_days" in raw_m:
v = _parse_chart_days(raw_m["chart_days"], label)
if v < 7 or v > 90:
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
out["chart_days"] = v
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
has_other = any(
out[k]
for k in (
"show_confidence_banner",
"show_correlation_c1_c3",
"show_drivers_c4",
)
)
if not has_section and not has_other:
raise ValueError(
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1C3) oder Treiber (C4) muss sichtbar sein"
)
return out
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]: def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
allowed = frozenset({"chart_days"}) allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed unknown = set(raw) - allowed

View File

@ -0,0 +1,256 @@
"""
Chart.js-kompatible Payloads für Lag-Korrelationen C1C3 und Treiber C4.
Gemeinsame Quelle für GET /charts/* und history_overview_viz.chart_payloads (Issue 53).
"""
from __future__ import annotations
from typing import Any, Dict
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
def build_weight_energy_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
if not corr_data or corr_data.get("correlation") is None:
msg = "Nicht genug Daten für Korrelationsanalyse"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
"layer_1": "correlations._correlate_energy_weight",
},
}
def build_lbm_protein_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
if not corr_data or corr_data.get("correlation") is None:
msg = "Nicht genug Daten für LBM-Protein Korrelation"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#3B82F6",
"borderColor": "#1E40AF",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_protein_lbm",
},
}
def build_load_vitals_correlation_chart_payload(profile_id: str, max_lag: int) -> Dict[str, Any]:
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
def _abs_corr(c: Any) -> float:
if not c or c.get("correlation") is None:
return -1.0
try:
return abs(float(c["correlation"]))
except (TypeError, ValueError):
return -1.0
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
msg = "Nicht genug Daten für Load-Vitals Korrelation"
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
if h_msg or r_msg:
msg = f"HRV: {h_msg or ''} · RHR: {r_msg or ''}"
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": msg,
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
},
}
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
corr_data = corr_hrv
metric_name = "HRV"
else:
corr_data = corr_rhr
metric_name = "RHR"
if not corr_data or corr_data.get("correlation") is None:
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
},
}
best_lag = corr_data.get("best_lag_days", corr_data.get("best_lag", 0))
correlation = corr_data.get("correlation", 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#F59E0B",
"borderColor": "#D97706",
"borderWidth": 2,
"pointRadius": 8,
}
],
},
"metadata": {
"confidence": corr_data.get("confidence", "low"),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"metric": metric_name,
"interpretation": corr_data.get("interpretation", ""),
"data_points": corr_data.get("data_points", 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_load_vitals",
},
}
def build_recovery_performance_chart_payload(profile_id: str) -> Dict[str, Any]:
drivers = calculate_top_drivers(profile_id)
if not drivers or len(drivers) == 0:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Nicht genug Daten für Driver-Analyse",
},
}
hindering = [d for d in drivers if d.get("impact", "") == "hindering"]
helpful = [d for d in drivers if d.get("impact", "") == "helpful"]
top_hindering = hindering[:3]
top_helpful = helpful[:3]
labels = []
values = []
colors = []
for d in top_hindering:
labels.append(f"{d.get('factor', '')}")
values.append(-abs(d.get("score", 0)))
colors.append("#EF4444")
for d in top_helpful:
labels.append(f"{d.get('factor', '')}")
values.append(abs(d.get("score", 0)))
colors.append("#1D9E75")
if not labels:
return {
"chart_type": "bar",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "low",
"data_points": 0,
"message": "Keine signifikanten Treiber gefunden",
},
}
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Impact Score",
"data": values,
"backgroundColor": colors,
"borderColor": "#085041",
"borderWidth": 1,
}
],
},
"metadata": {
"confidence": "medium",
"hindering_count": len(top_hindering),
"helpful_count": len(top_helpful),
"total_factors": len(drivers),
},
}

View File

@ -9,6 +9,12 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from data_layer.body_viz import get_body_history_viz_bundle from data_layer.body_viz import get_body_history_viz_bundle
from data_layer.correlation_chart_payloads import (
build_lbm_protein_correlation_chart_payload,
build_load_vitals_correlation_chart_payload,
build_recovery_performance_chart_payload,
build_weight_energy_correlation_chart_payload,
)
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
@ -181,6 +187,12 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any
"drivers": drv_list[:8], "drivers": drv_list[:8],
}, },
}, },
"chart_payloads": {
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
},
"meta": { "meta": {
"layer_1": "composed_metrics", "layer_1": "composed_metrics",
"layer_2b": "history_overview_viz", "layer_2b": "history_overview_viz",

View File

@ -14,7 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_admin, hash_pin from auth import require_admin, hash_pin
from models import AdminProfileUpdate from models import AdminProfileUpdate
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict from dashboard_layout_schema import (
ALLOWED_WIDGET_IDS,
DashboardLayoutPayload,
merge_missing_catalog_widgets,
product_default_layout_dict,
)
from dashboard_widget_entitlements import widgets_catalog_admin_payload from dashboard_widget_entitlements import widgets_catalog_admin_payload
from widget_catalog import WIDGET_CATALOG from widget_catalog import WIDGET_CATALOG
from widget_feature_requirements_db import ( from widget_feature_requirements_db import (
@ -184,7 +189,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
"""Aktueller Produkt-Dashboard-Standard (DB oder Code).""" """Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
_ = session _ = session
with get_db() as conn: with get_db() as conn:
layout = get_product_default_base_dict(conn) layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
from_database = get_stored_product_default_validated(conn) is not None from_database = get_stored_product_default_validated(conn) is not None
code_ref = product_default_layout_dict() code_ref = product_default_layout_dict()
return { return {
@ -217,7 +222,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
_ = session _ = session
with get_db() as conn: with get_db() as conn:
delete_product_default_override(conn) delete_product_default_override(conn)
layout = get_product_default_base_dict(conn) layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
return {"ok": True, "layout": layout, "from_database": False} return {"ok": True, "layout": layout, "from_database": False}

View File

@ -13,6 +13,7 @@ from dashboard_layout_schema import (
DashboardLayoutPayload, DashboardLayoutPayload,
coalesce_effective_layout, coalesce_effective_layout,
lab_default_layout_dict, lab_default_layout_dict,
merge_missing_catalog_widgets,
) )
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
from db import get_cursor, get_db from db import get_cursor, get_db
@ -51,9 +52,11 @@ def get_dashboard_layout(
raw = row["dashboard_layout"] if row else None raw = row["dashboard_layout"] if row else None
custom, effective = coalesce_effective_layout(raw) custom, effective = coalesce_effective_layout(raw)
with get_db() as conn: with get_db() as conn:
base_product = get_product_default_base_dict(conn) base_product = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
if not custom: if not custom:
effective = base_product effective = base_product
else:
effective = merge_missing_catalog_widgets(effective)
effective = apply_entitlements_to_layout_dict(effective, pid, conn) effective = apply_entitlements_to_layout_dict(effective, pid, conn)
product_adj = apply_entitlements_to_layout_dict(base_product, 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) lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)

View File

@ -69,10 +69,11 @@ from data_layer.recovery_metrics import (
calculate_rhr_vs_baseline_pct, calculate_rhr_vs_baseline_pct,
calculate_sleep_debt_hours calculate_sleep_debt_hours
) )
from data_layer.correlations import ( from data_layer.correlation_chart_payloads import (
calculate_lag_correlation, build_lbm_protein_correlation_chart_payload,
calculate_correlation_sleep_recovery, build_load_vitals_correlation_chart_payload,
calculate_top_drivers build_recovery_performance_chart_payload,
build_weight_energy_correlation_chart_payload,
) )
from data_layer.utils import serialize_dates, safe_float, calculate_confidence from data_layer.utils import serialize_dates, safe_float, calculate_confidence
from data_layer.nutrition_chart_payloads import ( from data_layer.nutrition_chart_payloads import (
@ -362,7 +363,8 @@ def get_history_overview_viz(
session: dict = Depends(require_auth), session: dict = Depends(require_auth),
) -> Dict: ) -> Dict:
""" """
Layer 2b: Gesamtansicht «Verlauf» KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1C4 (Metadaten). Layer 2b: Gesamtansicht «Verlauf» KPI-Kurzformen aus den vier History-Bundles,
Lag-Korrelationen C1C4 (Metadaten) und Chart.js-Payloads C1C4 (chart_payloads, wie /charts/*).
""" """
profile_id = session["profile_id"] profile_id = session["profile_id"]
bundle = get_history_overview_viz_bundle(profile_id, days) bundle = get_history_overview_viz_bundle(profile_id, days)
@ -1111,58 +1113,7 @@ def get_weight_energy_correlation_chart(
Chart.js scatter chart with correlation data Chart.js scatter chart with correlation data
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_weight_energy_correlation_chart_payload(profile_id, max_lag)
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
if not corr_data or corr_data.get('correlation') is None:
msg = "Nicht genug Daten für Korrelationsanalyse"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
}
}
# Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53)
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
correlation = corr_data.get('correlation', 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#1D9E75",
"borderColor": "#085041",
"borderWidth": 2,
"pointRadius": 8
}
]
},
"metadata": {
"confidence": corr_data.get('confidence', 'low'),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get('interpretation', ''),
"data_points": corr_data.get('data_points', 0),
"lag_details": corr_data.get("lag_details"),
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
"layer_1": "correlations._correlate_energy_weight",
}
}
@router.get("/lbm-protein-correlation") @router.get("/lbm-protein-correlation")
@ -1183,55 +1134,7 @@ def get_lbm_protein_correlation_chart(
Chart.js scatter chart with correlation data Chart.js scatter chart with correlation data
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_lbm_protein_correlation_chart_payload(profile_id, max_lag)
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
if not corr_data or corr_data.get('correlation') is None:
msg = "Nicht genug Daten für LBM-Protein Korrelation"
if isinstance(corr_data, dict):
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
return {
"chart_type": "scatter",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
"message": msg,
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
}
}
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
correlation = corr_data.get('correlation', 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Lag {best_lag} Tage"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#3B82F6",
"borderColor": "#1E40AF",
"borderWidth": 2,
"pointRadius": 8
}
]
},
"metadata": {
"confidence": corr_data.get('confidence', 'low'),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"interpretation": corr_data.get('interpretation', ''),
"data_points": corr_data.get('data_points', 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_protein_lbm",
}
}
@router.get("/load-vitals-correlation") @router.get("/load-vitals-correlation")
@ -1252,83 +1155,7 @@ def get_load_vitals_correlation_chart(
Chart.js scatter chart with correlation data Chart.js scatter chart with correlation data
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_load_vitals_correlation_chart_payload(profile_id, max_lag)
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
def _abs_corr(c):
if not c or c.get("correlation") is None:
return -1.0
try:
return abs(float(c["correlation"]))
except (TypeError, ValueError):
return -1.0
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
msg = "Nicht genug Daten für Load-Vitals Korrelation"
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
if h_msg or r_msg:
msg = f"HRV: {h_msg or ''} · RHR: {r_msg or ''}"
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": msg,
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
},
}
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
corr_data = corr_hrv
metric_name = "HRV"
else:
corr_data = corr_rhr
metric_name = "RHR"
if not corr_data or corr_data.get("correlation") is None:
return {
"chart_type": "scatter",
"data": {"labels": [], "datasets": []},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
},
}
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
correlation = corr_data.get('correlation', 0)
return {
"chart_type": "scatter",
"data": {
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
"datasets": [
{
"label": "Korrelation",
"data": [{"x": best_lag, "y": correlation}],
"backgroundColor": "#F59E0B",
"borderColor": "#D97706",
"borderWidth": 2,
"pointRadius": 8
}
]
},
"metadata": {
"confidence": corr_data.get('confidence', 'low'),
"correlation": round(float(correlation), 3),
"best_lag_days": best_lag,
"metric": metric_name,
"interpretation": corr_data.get('interpretation', ''),
"data_points": corr_data.get('data_points', 0),
"lag_details": corr_data.get("lag_details"),
"layer_1": "correlations._correlate_load_vitals",
}
}
@router.get("/recovery-performance") @router.get("/recovery-performance")
@ -1347,81 +1174,7 @@ def get_recovery_performance_chart(
Chart.js bar chart with top drivers Chart.js bar chart with top drivers
""" """
profile_id = session['profile_id'] profile_id = session['profile_id']
return build_recovery_performance_chart_payload(profile_id)
# Get top drivers (hindering/helpful factors)
drivers = calculate_top_drivers(profile_id)
if not drivers or len(drivers) == 0:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": 0,
"message": "Nicht genug Daten für Driver-Analyse"
}
}
# Separate hindering and helpful
hindering = [d for d in drivers if d.get('impact', '') == 'hindering']
helpful = [d for d in drivers if d.get('impact', '') == 'helpful']
# Take top 3 of each
top_hindering = hindering[:3]
top_helpful = helpful[:3]
labels = []
values = []
colors = []
for d in top_hindering:
labels.append(f"{d.get('factor', '')}")
values.append(-abs(d.get('score', 0))) # Negative for hindering
colors.append("#EF4444")
for d in top_helpful:
labels.append(f"{d.get('factor', '')}")
values.append(abs(d.get('score', 0))) # Positive for helpful
colors.append("#1D9E75")
if not labels:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "low",
"data_points": 0,
"message": "Keine signifikanten Treiber gefunden"
}
}
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Impact Score",
"data": values,
"backgroundColor": colors,
"borderColor": "#085041",
"borderWidth": 1
}
]
},
"metadata": {
"confidence": "medium",
"hindering_count": len(top_hindering),
"helpful_count": len(top_helpful),
"total_factors": len(drivers)
}
}
# ── Health Endpoint ────────────────────────────────────────────────────────── # ── Health Endpoint ──────────────────────────────────────────────────────────

View File

@ -5,6 +5,7 @@ from dashboard_layout_schema import (
DashboardLayoutPayload, DashboardLayoutPayload,
coalesce_effective_layout, coalesce_effective_layout,
default_layout_dict, default_layout_dict,
merge_missing_catalog_widgets,
) )
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
@ -56,3 +57,19 @@ def test_coalesce_valid_raw():
custom, eff = coalesce_effective_layout(raw) custom, eff = coalesce_effective_layout(raw)
assert custom is True assert custom is True
assert eff == raw assert eff == raw
def test_merge_missing_catalog_widgets_keeps_order_and_fills_ids():
raw = {
"version": 1,
"widgets": [
{"id": "kpi_board", "enabled": True, "config": {}},
{"id": "welcome", "enabled": False, "config": {}},
],
}
merged = merge_missing_catalog_widgets(raw)
assert [w["id"] for w in merged["widgets"][:2]] == ["kpi_board", "welcome"]
assert {w["id"] for w in merged["widgets"]} == ALLOWED_WIDGET_IDS
extra = [w for w in merged["widgets"] if w["id"] not in ("kpi_board", "welcome")]
assert all(w["enabled"] is False for w in extra)
DashboardLayoutPayload.model_validate(merged)

View File

@ -14,6 +14,196 @@ def test_body_chart_days_bounds():
validate_widget_entry_config("body_overview", {"chart_days": 91}) validate_widget_entry_config("body_overview", {"chart_days": 91})
def test_body_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("body_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_weight_chart"] is True
assert d["kpi_detail"] == "compact"
assert d["show_body_fat_chart"] is False
def test_body_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("body_history_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_goals_strip"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
def test_body_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"body_history_viz",
{"show_kpis": False, "show_weight_chart": False},
)
def test_body_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("body_history_viz", {"evil": True})
def test_nutrition_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("nutrition_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_kcal_vs_weight"] is True
assert d["kpi_detail"] == "compact"
assert d["show_calorie_balance_chart"] is False
assert d["show_energy_protein_charts"] is False
def test_nutrition_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45})
assert d["chart_days"] == 45
assert d["show_goals_strip"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5})
def test_nutrition_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"nutrition_history_viz",
{"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False},
)
def test_nutrition_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("nutrition_history_viz", {"evil": True})
def test_fitness_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("fitness_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_chart_training_volume"] is True
assert d["kpi_detail"] == "compact"
assert d["show_layer_meta"] is False
assert d["show_chart_load_monitoring"] is False
def test_fitness_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_progress_insights"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("fitness_history_viz", {"chart_days": 5})
def test_fitness_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"fitness_history_viz",
{
"show_kpis": False,
"show_progress_insights": False,
"show_chart_training_volume": False,
"show_chart_training_type_distribution": False,
"show_chart_quality_sessions": False,
"show_chart_load_monitoring": False,
},
)
def test_fitness_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("fitness_history_viz", {"evil": True})
def test_recovery_history_viz_empty_expands_defaults():
d = validate_widget_entry_config("recovery_history_viz", {})
assert d["chart_days"] == 30
assert d["show_kpis"] is True
assert d["show_chart_recovery_score"] is True
assert d["kpi_detail"] == "compact"
assert d["show_heart_context_card"] is False
assert d["show_vitals_extra_trends"] is False
def test_recovery_history_viz_chart_days_and_merge():
d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42})
assert d["chart_days"] == 42
assert d["show_layer_meta"] is False
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"chart_days": 3})
def test_recovery_history_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"recovery_history_viz",
{
"show_kpis": False,
"show_progress_insights": False,
"show_heart_context_card": False,
"show_vitals_extra_trends": False,
"show_chart_recovery_score": False,
"show_chart_sleep_quality": False,
"show_chart_sleep_debt": False,
"show_chart_hrv_rhr": False,
},
)
def test_recovery_history_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("recovery_history_viz", {"evil": True})
def test_history_overview_viz_empty_expands_defaults():
d = validate_widget_entry_config("history_overview_viz", {})
assert d["chart_days"] == 30
assert d["show_confidence_banner"] is True
assert d["show_section_body"] is True
assert d["show_section_nutrition"] is True
assert d["show_section_fitness"] is True
assert d["show_section_recovery"] is True
assert d["show_correlation_c1_c3"] is True
assert d["show_drivers_c4"] is True
def test_history_overview_viz_chart_days_and_merge():
d = validate_widget_entry_config("history_overview_viz", {"chart_days": 60})
assert d["chart_days"] == 60
assert d["show_intro_blurb"] is True
with pytest.raises(ValueError):
validate_widget_entry_config("history_overview_viz", {"chart_days": 5})
def test_history_overview_viz_requires_visible_block():
with pytest.raises(ValueError):
validate_widget_entry_config(
"history_overview_viz",
{
"show_confidence_banner": False,
"show_section_body": False,
"show_section_nutrition": False,
"show_section_fitness": False,
"show_section_recovery": False,
"show_correlation_c1_c3": False,
"show_drivers_c4": False,
},
)
def test_history_overview_viz_legacy_show_area_summaries_maps_sections():
d = validate_widget_entry_config(
"history_overview_viz",
{"show_area_summaries": False, "show_correlation_c1_c3": True},
)
assert d["show_section_body"] is False
assert d["show_section_fitness"] is False
assert d["show_correlation_c1_c3"] is True
def test_history_overview_viz_unknown_key():
with pytest.raises(ValueError):
validate_widget_entry_config("history_overview_viz", {"evil": True})
def test_welcome_config_rejected_unknown_key(): def test_welcome_config_rejected_unknown_key():
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_widget_entry_config("welcome", {"x": 1}) validate_widget_entry_config("welcome", {"x": 1})

View File

@ -32,7 +32,9 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
"version": 1, "version": 1,
"widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)],
} }
DashboardLayoutPayload.model_validate(small) # Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert
# (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz / recovery_history_viz: leere config → volle Defaults in to_stored_dict).
expected = DashboardLayoutPayload.model_validate(small).to_stored_dict()
class _Cur: class _Cur:
def execute(self, *a, **k): def execute(self, *a, **k):
@ -42,4 +44,4 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
return {"value": small} return {"value": small}
monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur())
assert get_product_default_base_dict(object()) == small assert get_product_default_base_dict(object()) == expected

View File

@ -24,13 +24,13 @@ MODULE_VERSIONS = {
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
"prompts": "1.1.0", "prompts": "1.1.0",
"admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041) "admin": "1.4.1", # Produkt-Dashboard-Standard GET/DELETE: merge_missing_catalog_widgets
"stats": "1.0.1", "stats": "1.0.1",
"exportdata": "1.1.0", "exportdata": "1.1.0",
"importdata": "1.0.0", "importdata": "1.0.0",
"membership": "2.1.0", "membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode) "workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog "app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise "csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response) "admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
} }

View File

@ -42,6 +42,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Gewicht & Kennzahlen (optional: config chart_days 790); Feature weight_entries", "description": "Gewicht & Kennzahlen (optional: config chart_days 790); Feature weight_entries",
"requires_feature": "weight_entries", "requires_feature": "weight_entries",
}, },
{
"id": "body_history_viz",
"title": "Körper (Verlauf-Bundle)",
"description": "Layer-2b body-history-viz: schlanker Standard (KPI kompakt + Gewicht); optional Blöcke/Charts per config (show_* , kpi_detail); chart_days 790; Feature weight_entries",
"requires_feature": "weight_entries",
},
{ {
"id": "activity_overview", "id": "activity_overview",
"title": "Aktivität", "title": "Aktivität",
@ -94,6 +100,28 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); Feature nutrition_entries", "description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); Feature nutrition_entries",
"requires_feature": "nutrition_entries", "requires_feature": "nutrition_entries",
}, },
{
"id": "nutrition_history_viz",
"title": "Ernährung (Verlauf-Bundle)",
"description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790; Feature nutrition_entries",
"requires_feature": "nutrition_entries",
},
{
"id": "fitness_history_viz",
"title": "Fitness (Verlauf-Bundle)",
"description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790; Feature activity_entries",
"requires_feature": "activity_entries",
},
{
"id": "recovery_history_viz",
"title": "Erholung (Verlauf-Bundle)",
"description": "Layer-2b recovery-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 790",
},
{
"id": "history_overview_viz",
"title": "Verlauf — Gesamtübersicht",
"description": "Layer-2b history-overview-viz: Kurzinfos pro Bereich (show_section_body/nutrition/fitness/recovery) + C1C4; chart_payloads; chart_days 790",
},
{ {
"id": "recovery_charts_panel", "id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5", "title": "Erholung — Charts R1R5",

View File

@ -79,6 +79,7 @@ function Nav({ isAdmin }) {
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
state={item.to === '/history' ? { tab: 'overview' } : undefined}
end={!!item.end} end={!!item.end}
className={({ isActive }) => className={({ isActive }) =>
'nav-item' + 'nav-item' +

View File

@ -35,6 +35,7 @@ export default function DesktopSidebar({
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
state={item.to === '/history' ? { tab: 'overview' } : undefined}
end={!!item.end} end={!!item.end}
className={({ isActive }) => className={({ isActive }) =>
'desktop-sidebar__link' + 'desktop-sidebar__link' +

View File

@ -17,6 +17,11 @@ import {
import { api } from '../utils/api' import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview' import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor } from '../utils/interpret' import { getStatusColor } from '../utils/interpret'
import {
FITNESS_HISTORY_VIZ_HISTORY_FULL,
filterFitnessHistoryKpiTiles,
normalizeFitnessHistoryVizConfig,
} from '../widgetSystem/fitnessHistoryVizConfig'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const PERIODS = [ const PERIODS = [
@ -28,21 +33,35 @@ const PERIODS = [
/** /**
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics). * Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
* @param {number} [props.externalPeriod] feste Tage (z. B. Dashboard-Widget 790)
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] Dashboard-Config; undefined = voller Verlauf
* @param {import('react').ReactNode} [props.footer]
*/ */
export default function FitnessDashboardOverview({ export default function FitnessDashboardOverview({
period: periodProp, period: periodProp,
onPeriodChange, onPeriodChange,
hidePeriodSelector = false, hidePeriodSelector = false,
externalPeriod,
embedded = false,
visibility,
footer = null,
}) { }) {
const nav = useNavigate() const nav = useNavigate()
const [internalPeriod, setInternalPeriod] = useState(28) const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period = controlled ? periodProp : internalPeriod const period =
const setPeriod = controlled ? onPeriodChange : setInternalPeriod externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
const setPeriod =
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
const [viz, setViz] = useState(null) const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null) const [err, setErr] = useState(null)
const display = visibility === undefined ? FITNESS_HISTORY_VIZ_HISTORY_FULL : normalizeFitnessHistoryVizConfig(visibility)
const chartH = embedded ? 176 : 200
const chartLoadH = embedded ? 200 : 220
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
setLoading(true) setLoading(true)
@ -63,10 +82,13 @@ export default function FitnessDashboardOverview({
} }
}, [period]) }, [period])
const outerClass = embedded ? '' : 'card section-gap'
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
if (loading) { if (loading) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Fitness-Übersicht</div> {!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div className="spinner" style={{ margin: 24 }} /> <div className="spinner" style={{ margin: 24 }} />
</div> </div>
) )
@ -74,8 +96,8 @@ export default function FitnessDashboardOverview({
if (err) { if (err) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Fitness-Übersicht</div> {!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div style={{ color: 'var(--danger)' }}>{err}</div> <div style={{ color: 'var(--danger)' }}>{err}</div>
</div> </div>
) )
@ -83,8 +105,8 @@ export default function FitnessDashboardOverview({
if (!viz?.has_activity_entries) { if (!viz?.has_activity_entries) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Fitness-Übersicht</div> {!embedded && <div className="card-title">Fitness-Übersicht</div>}
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}> <p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier. Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
</p> </p>
@ -130,11 +152,14 @@ export default function FitnessDashboardOverview({
})) }))
const loadMeta = loadCh?.metadata || {} const loadMeta = loadCh?.metadata || {}
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
...t, ...t,
sublabel: sublabel:
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel, typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel,
})) }))
const kpiTilesShown = display.show_kpis
? filterFitnessHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
: []
const insights = viz.progress_insights || [] const insights = viz.progress_insights || []
const eff = viz.effective_window_days const eff = viz.effective_window_days
@ -142,49 +167,59 @@ export default function FitnessDashboardOverview({
const dTyp = viz.training_type_dist_days_used const dTyp = viz.training_type_dist_days_used
const loadDays = viz.load_chart_days_used const loadDays = viz.load_chart_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled const gridWrapStyle = { width: '100%', minWidth: 0 }
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}> {!embedded && (
<span>Fitness-Übersicht</span> <div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
{showPeriodDropdown ? ( <span>Fitness-Übersicht</span>
<label {showPeriodDropdown ? (
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }} <label
> style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
> >
{PERIODS.map((p) => ( Zeitraum
<option key={p.v} value={p.v}> <select
{p.label} className="form-input"
</option> style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
))} value={period}
</select> onChange={(e) => setPeriod(Number(e.target.value))}
</label> >
) : null} {PERIODS.map((p) => (
</div> <option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}> {embedded && viz?.last_updated ? (
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '} <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '} Letzte Aktivität {viz.last_updated}
<strong>{loadDays ?? '—'}</strong> Tage </div>
{viz.last_updated ? ( ) : null}
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" /> {display.show_layer_meta ? (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
<strong>{loadDays ?? '—'}</strong> Tage
{viz.last_updated ? (
<>
{' '}
· letzte Aktivität <strong>{viz.last_updated}</strong>
</>
) : null}
.
</p>
) : null}
{insights.length > 0 ? ( {kpiTilesShown.length > 0 ? <KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" /> : null}
{display.show_progress_insights && insights.length > 0 ? (
<div style={{ marginBottom: 14 }}> <div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@ -213,136 +248,155 @@ export default function FitnessDashboardOverview({
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 16, gap: 16,
marginTop: 8, marginTop: 8,
minWidth: 0,
}} }}
> >
<div> {display.show_chart_training_volume ? (
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}> <div style={gridWrapStyle}>
Trainingsvolumen (Minuten / Woche) <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
</div> Trainingsvolumen (Minuten / Woche)
{volRows.length >= 1 ? ( </div>
<ResponsiveContainer width="100%" height={200}> {volRows.length >= 1 ? (
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <ResponsiveContainer width="100%" height="100%">
<XAxis <BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
dataKey="name" <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
tick={{ fontSize: 9, fill: 'var(--text3)' }} <XAxis
tickLine={false} dataKey="name"
interval={0} tick={{ fontSize: 9, fill: 'var(--text3)' }}
angle={-35} tickLine={false}
textAnchor="end" interval={0}
height={48} angle={-35}
/> textAnchor="end"
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> height={48}
<Tooltip />
contentStyle={{ <YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
background: 'var(--surface)', <Tooltip
border: '1px solid var(--border)', contentStyle={{
borderRadius: 8, background: 'var(--surface)',
fontSize: 11, border: '1px solid var(--border)',
}} borderRadius: 8,
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']} fontSize: 11,
/> }}
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" /> formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
</BarChart> />
</ResponsiveContainer> <Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
) : ( </BarChart>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div> </ResponsiveContainer>
)}
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
{pieData.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={72}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
</PieChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
)}
</div>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Qualitäts-Sessions (Schätzung)
</div>
{qualBar.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
{qualBar.map((entry, i) => (
<Cell key={`q-${i}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
)}
</div>
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Belastung (Proxy-Load · duration×RPE / Tag)
</div>
{loadRows.length >= 1 ? (
<>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,81,3'} · Proxy)
</div> </div>
</> ) : (
) : ( <div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div> )}
)} </div>
</div> ) : null}
{display.show_chart_training_type_distribution ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
{pieData.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={72}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
)}
</div>
) : null}
{display.show_chart_quality_sessions ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Qualitäts-Sessions (Schätzung)
</div>
{qualBar.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
{qualBar.map((entry, i) => (
<Cell key={`q-${i}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
)}
</div>
) : null}
{display.show_chart_load_monitoring ? (
<div style={{ ...gridWrapStyle, gridColumn: '1 / -1', maxWidth: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Belastung (Proxy-Load · duration×RPE / Tag)
</div>
{loadRows.length >= 1 ? (
<>
<div style={{ width: '100%', minWidth: 0, height: chartLoadH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
</LineChart>
</ResponsiveContainer>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,81,3'} · Proxy)
</div>
</>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
)}
</div>
) : null}
</div> </div>
{footer}
</div> </div>
) )
} }

View File

@ -1,8 +1,8 @@
import RecoveryDashboardOverview from './RecoveryDashboardOverview' import RecoveryDashboardOverview from './RecoveryDashboardOverview'
/** /**
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days period). * @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper (days externalPeriod).
*/ */
export default function RecoveryCharts({ days = 28 }) { export default function RecoveryCharts({ days = 28 }) {
return <RecoveryDashboardOverview period={days} hidePeriodSelector /> return <RecoveryDashboardOverview externalPeriod={days} hidePeriodSelector />
} }

View File

@ -4,6 +4,11 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
import { api } from '../utils/api' import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview' import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor, getStatusBg } from '../utils/interpret' import { getStatusColor, getStatusBg } from '../utils/interpret'
import {
RECOVERY_HISTORY_VIZ_HISTORY_FULL,
filterRecoveryHistoryKpiTiles,
normalizeRecoveryHistoryVizConfig,
} from '../widgetSystem/recoveryHistoryVizConfig'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.') const fmtDate = (d) => dayjs(d).format('DD.MM.')
@ -194,17 +199,32 @@ function ChartCard({ title, loading, error, children, description }) {
/** /**
* Layer 2b: Erholung ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics). * Layer 2b: Erholung ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics).
* @param {number} [props.externalPeriod] Widget: feste Tage (790)
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] Dashboard-Config; undefined = voller Verlauf
* @param {import('react').ReactNode} [props.footer]
*/ */
export default function RecoveryDashboardOverview({ export default function RecoveryDashboardOverview({
period: periodProp, period: periodProp,
onPeriodChange, onPeriodChange,
hidePeriodSelector = false, hidePeriodSelector = false,
externalPeriod,
embedded = false,
visibility,
footer = null,
}) { }) {
const nav = useNavigate() const nav = useNavigate()
const [internalPeriod, setInternalPeriod] = useState(28) const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function' const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period = controlled ? periodProp : internalPeriod const period =
const setPeriod = controlled ? onPeriodChange : setInternalPeriod externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
const setPeriod =
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
const display =
visibility === undefined ? RECOVERY_HISTORY_VIZ_HISTORY_FULL : normalizeRecoveryHistoryVizConfig(visibility)
const chartH = embedded ? 176 : 200
const chartHVitals = embedded ? 200 : 220
const [viz, setViz] = useState(null) const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -230,10 +250,13 @@ export default function RecoveryDashboardOverview({
} }
}, [period]) }, [period])
const outerClass = embedded ? '' : 'card section-gap'
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
if (loading) { if (loading) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="spinner" style={{ margin: 24 }} /> <div className="spinner" style={{ margin: 24 }} />
</div> </div>
) )
@ -241,8 +264,8 @@ export default function RecoveryDashboardOverview({
if (err) { if (err) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div style={{ color: 'var(--danger)' }}>{err}</div> <div style={{ color: 'var(--danger)' }}>{err}</div>
</div> </div>
) )
@ -250,8 +273,8 @@ export default function RecoveryDashboardOverview({
if (!viz?.has_recovery_data) { if (!viz?.has_recovery_data) {
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title">Erholung & Vitalwerte</div> {!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}> <p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst {viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst
oder importierst, erscheinen Auswertungen hier. oder importierst, erscheinen Auswertungen hier.
@ -280,18 +303,19 @@ export default function RecoveryDashboardOverview({
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2') const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean) const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({ const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
...t, ...t,
sublabel: sublabel:
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel, typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}` : t.sublabel,
})) }))
const kpiTilesShown = display.show_kpis
? filterRecoveryHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
: []
const insights = viz.progress_insights || [] const insights = viz.progress_insights || []
const eff = viz.effective_window_days const eff = viz.effective_window_days
const cDays = viz.chart_days_used const cDays = viz.chart_days_used
const vDays = viz.vital_matrix_days_used const vDays = viz.vital_matrix_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled
const renderRecoveryScore = () => { const renderRecoveryScore = () => {
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') { if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
return ( return (
@ -306,41 +330,43 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis />
tick={{ fontSize: 9, fill: 'var(--text3)' }} <YAxis
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
domain={[0, 100]} tickLine={false}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')} domain={[0, 100]}
tickCount={6} tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
width={36} tickCount={6}
/> width={36}
<Tooltip />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line />
type="monotone" <Line
dataKey="score" type="monotone"
stroke="#1D9E75" dataKey="score"
strokeWidth={2} stroke="#1D9E75"
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'} strokeWidth={2}
dot={{ r: 2 }} name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
/> dot={{ r: 2 }}
</LineChart> />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}>
KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '} KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '}
{recoveryData.metadata.data_points} {recoveryData.metadata.data_points}
@ -364,44 +390,46 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis />
yAxisId="left" <YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} yAxisId="left"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick} tickLine={false}
tickCount={6} tickFormatter={formatAxisTick}
width={44} tickCount={6}
/> width={44}
<YAxis />
yAxisId="right" <YAxis
orientation="right" yAxisId="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }} orientation="right"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickFormatter={formatAxisTick} tickLine={false}
tickCount={6} tickFormatter={formatAxisTick}
width={44} tickCount={6}
/> width={44}
<Tooltip />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} /> />
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} /> <Line yAxisId="left" type="monotone" dataKey="hrv" stroke="#1D9E75" strokeWidth={2} name="HRV (ms)" dot={{ r: 2 }} />
</LineChart> <Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
</div> </div>
@ -424,29 +452,31 @@ export default function RecoveryDashboardOverview({
})) }))
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> />
<YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} /> <YAxis yAxisId="left" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip <YAxis yAxisId="right" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} /> />
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} /> <Line yAxisId="left" type="monotone" dataKey="duration" stroke="#3B82F6" strokeWidth={2} name="Dauer (h)" dot={{ r: 2 }} />
</LineChart> <Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Ø {sleepData.metadata.avg_duration_hours}h Schlaf Ø {sleepData.metadata.avg_duration_hours}h Schlaf
</div> </div>
@ -469,35 +499,37 @@ export default function RecoveryDashboardOverview({
const curDebt = debtData.metadata?.current_debt_hours const curDebt = debtData.metadata?.current_debt_hours
return ( return (
<> <>
<ResponsiveContainer width="100%" height={200}> <div style={{ width: '100%', minWidth: 0, height: chartH }}>
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}> <ResponsiveContainer width="100%" height="100%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
<XAxis <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
dataKey="date" <XAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }} dataKey="date"
tickLine={false} tick={{ fontSize: 9, fill: 'var(--text3)' }}
interval={Math.max(0, Math.floor(chartData.length / 6) - 1)} tickLine={false}
/> interval={Math.max(0, Math.floor(chartData.length / 6) - 1)}
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} /> />
<Tooltip <YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
contentStyle={{ <Tooltip
background: 'var(--surface)', contentStyle={{
border: '1px solid var(--border)', background: 'var(--surface)',
borderRadius: 8, border: '1px solid var(--border)',
fontSize: 11, borderRadius: 8,
}} fontSize: 11,
/> }}
<Line />
type="monotone" <Line
dataKey="debt" type="monotone"
stroke="#EF4444" dataKey="debt"
strokeWidth={2} stroke="#EF4444"
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'} strokeWidth={2}
dot={{ r: 2 }} name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
connectNulls dot={{ r: 2 }}
/> connectNulls
</LineChart> />
</ResponsiveContainer> </LineChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}> <div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
</div> </div>
@ -577,7 +609,7 @@ export default function RecoveryDashboardOverview({
Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen. Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen.
</div> </div>
) : ( ) : (
<div style={{ width: '100%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}> <div style={{ width: '100%', minWidth: 0, height: hasMa ? chartHVitals : chartH, minHeight: hasMa ? chartHVitals : chartH }}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}> <LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" /> <CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
@ -632,37 +664,43 @@ export default function RecoveryDashboardOverview({
} }
return ( return (
<div className="card section-gap"> <div className={outerClass || undefined}>
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}> {!embedded && (
<span>Erholung & Vitalwerte</span> <div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
{showPeriodDropdown ? ( <span>Erholung & Vitalwerte</span>
<label {showPeriodDropdown ? (
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }} <label
> style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
> >
<option value={7}>7 Tage</option> Zeitraum
<option value={28}>28 Tage</option> <select
<option value={90}>90 Tage</option> className="form-input"
<option value={9999}>Gesamt</option> style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
</select> value={period}
</label> onChange={(e) => setPeriod(Number(e.target.value))}
) : null} >
</div> <option value={7}>7 Tage</option>
<option value={28}>28 Tage</option>
<option value={90}>90 Tage</option>
<option value={9999}>Gesamt</option>
</select>
</label>
) : null}
</div>
)}
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}> {display.show_layer_meta ? (
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage · <p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Vital-Snapshot <strong>{vDays}</strong> Tage. Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
</p> Vital-Snapshot <strong>{vDays}</strong> Tage.
</p>
) : null}
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} /> {kpiTilesShown.length > 0 ? (
<KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" marginBottom={16} />
) : null}
{insights.length > 0 ? ( {display.show_progress_insights && insights.length > 0 ? (
<div style={{ marginBottom: 18 }}> <div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Überblick: Recovery & Schlaf Überblick: Recovery & Schlaf
@ -690,93 +728,113 @@ export default function RecoveryDashboardOverview({
</div> </div>
) : null} ) : null}
<SectionHeading {display.show_sleep_section_heading ? (
compactTop <SectionHeading
title="Schlaf & Erholung" compactTop
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben." title="Schlaf & Erholung"
/> hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
<ChartCard />
title="HRV-Verlauf (kein Recovery-Score)" ) : null}
description={ {display.show_chart_recovery_score ? (
'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' + <ChartCard
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).' title="HRV-Verlauf (kein Recovery-Score)"
} description={
> 'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' +
{renderRecoveryScore()} 'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
</ChartCard> }
<ChartCard >
title="Schlaf: Dauer & Qualität" {renderRecoveryScore()}
description={ </ChartCard>
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null ) : null}
? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.` {display.show_chart_sleep_quality ? (
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.' <ChartCard
} title="Schlaf: Dauer & Qualität"
> description={
{renderSleepQuality()} sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != null
</ChartCard> ? `Dauer (h) und Qualitätsanteil (%). Mittlere Schlafdauer im Chart-Fenster: ${sleepData.metadata.avg_duration_hours} h — gleiche Information wie früher in der KPI «Ø Schlafdauer», jetzt hier im Schlaf-Kontext.`
<ChartCard : 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
title="Schlafschuld" }
description={ >
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' + {renderSleepQuality()}
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' + </ChartCard>
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.' ) : null}
} {display.show_chart_sleep_debt ? (
> <ChartCard
{renderSleepDebt()} title="Schlafschuld"
</ChartCard> description={
'Gleiche Berechnung wie die KPI: Summe der nächtlichen Defizite gegenüber 7,5 h/Nacht im rollierenden 14-Tage-Fenster ' +
'(Ziel derzeit fest im Code, nicht in den Einstellungen). Jeder Punkt = Schlafschuld mit Fensterende an diesem Datum — ' +
'entspricht der KPI, wenn der letzte Punkt die letzte erfasste Nacht ist.'
}
>
{renderSleepDebt()}
</ChartCard>
) : null}
<SectionHeading {display.show_heart_section_heading ? (
title="Herz & Kreislauf" <SectionHeading
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)." title="Herz & Kreislauf"
/> hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
<div className="card" style={{ marginBottom: 12 }}> />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div> ) : null}
<HeartAutonomicGuide /> {display.show_heart_context_card ? (
{heartSectionInsights.length > 0 ? ( <div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}> <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
{heartSectionInsights.map((ins) => ( <HeartAutonomicGuide />
<SectionInsightCard key={ins.key} ins={ins} /> {heartSectionInsights.length > 0 ? (
))} <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
</div> {heartSectionInsights.map((ins) => (
) : null} <SectionInsightCard key={ins.key} ins={ins} />
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div> ))}
<SnapshotCards items={heartSnapshotItems} /> </div>
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? ( ) : null}
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
{vitalsData?.metadata?.vitals_measured_at ? ( <SnapshotCards items={heartSnapshotItems} />
<> {vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong> <div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
</> {vitalsData?.metadata?.vitals_measured_at ? (
) : null} <>
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null} Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
{vitalsData?.metadata?.blood_pressure_measured_at ? ( </>
<> ) : null}
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong> {vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
</> {vitalsData?.metadata?.blood_pressure_measured_at ? (
) : null} <>
</div> Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
) : null} </>
{vitalsData?.metadata?.disclaimer_de ? ( ) : null}
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}> </div>
{vitalsData.metadata.disclaimer_de} ) : null}
</div> {vitalsData?.metadata?.disclaimer_de ? (
) : null} <div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
</div> {vitalsData.metadata.disclaimer_de}
<ChartCard </div>
title="HRV & Ruhepuls — Zeitverlauf" ) : null}
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben." </div>
> ) : null}
{renderHrvRhr()} {display.show_chart_hrv_rhr ? (
</ChartCard> <ChartCard
title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
>
{renderHrvRhr()}
</ChartCard>
) : null}
<SectionHeading {display.show_vitals_extra_heading ? (
title="Weitere Vitalparameter (Verlauf)" <SectionHeading
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel." title="Weitere Vitalparameter (Verlauf)"
/> hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
<div className="card" style={{ marginBottom: 12 }}> />
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div> ) : null}
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)} {display.show_vitals_extra_trends ? (
</div> <div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div>
) : null}
{footer}
</div> </div>
) )
} }

View File

@ -0,0 +1,37 @@
import { useNavigate } from 'react-router-dom'
import BodyHistoryVizSection from '../history/BodyHistoryVizSection'
import { useProfile } from '../../context/ProfileContext'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
import { normalizeBodyHistoryVizConfig } from '../../widgetSystem/bodyHistoryVizConfig'
/**
* Verlauf Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), Umfang über Layout-Config.
* @param {{ refreshTick?: number, bodyHistoryVizConfig?: Record<string, unknown> }} props
*/
export default function BodyHistoryVizWidget({ refreshTick = 0, bodyHistoryVizConfig }) {
const nav = useNavigate()
const { activeProfile } = useProfile()
const cfg = normalizeBodyHistoryVizConfig(bodyHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
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)' }}>Körper (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>body-history-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'body' } })}>
Verlauf
</button>
</div>
<BodyHistoryVizSection
key={`${refreshTick}-${days}`}
profile={activeProfile}
externalPeriod={days}
embedded
visibility={cfg}
/>
</div>
)
}

View File

@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom'
import FitnessHistoryVizSection from '../history/FitnessHistoryVizSection'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
import { normalizeFitnessHistoryVizConfig } from '../../widgetSystem/fitnessHistoryVizConfig'
/**
* Verlauf Fitness als Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b), Umfang über Layout-Config.
* @param {{ refreshTick?: number, fitnessHistoryVizConfig?: Record<string, unknown> }} props
*/
export default function FitnessHistoryVizWidget({ refreshTick = 0, fitnessHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeFitnessHistoryVizConfig(fitnessHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
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)' }}>Fitness (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>fitness-dashboard-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'activity' } })}>
Verlauf
</button>
</div>
<FitnessHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
</div>
)
}

View File

@ -0,0 +1,35 @@
import { useNavigate } from 'react-router-dom'
import HistoryOverviewVizSection from '../history/HistoryOverviewVizSection'
import { normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
/**
* Verlauf Gesamtübersicht als Dashboard-Widget: GET /charts/history-overview-viz (inkl. chart_payloads C1C4).
* @param {{ refreshTick?: number, historyOverviewVizConfig?: Record<string, unknown> }} props
*/
export default function HistoryOverviewVizWidget({ refreshTick = 0, historyOverviewVizConfig }) {
const nav = useNavigate()
const cfg = normalizeHistoryOverviewVizConfig(historyOverviewVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
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)' }}>Gesamtübersicht (Verlauf)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>history-overview-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'overview' } })}>
Verlauf
</button>
</div>
<HistoryOverviewVizSection
key={`${refreshTick}-${days}`}
externalPeriod={days}
hidePeriodSelector
embedded
visibility={cfg}
/>
</div>
)
}

View File

@ -0,0 +1,34 @@
import { useNavigate } from 'react-router-dom'
import NutritionHistoryVizSection from '../history/NutritionHistoryVizSection'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
import { normalizeNutritionHistoryVizConfig } from '../../widgetSystem/nutritionHistoryVizConfig'
/**
* Verlauf Ernährung als Dashboard-Widget: GET /charts/nutrition-history-viz (Layer 2b), Umfang über Layout-Config.
* @param {{ refreshTick?: number, nutritionHistoryVizConfig?: Record<string, unknown> }} props
*/
export default function NutritionHistoryVizWidget({ refreshTick = 0, nutritionHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeNutritionHistoryVizConfig(nutritionHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
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 (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nutrition-history-viz · {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>
<NutritionHistoryVizSection
key={`${refreshTick}-${days}`}
externalPeriod={days}
embedded
visibility={cfg}
/>
</div>
)
}

View File

@ -26,7 +26,7 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }
Verlauf Verlauf
</button> </button>
</div> </div>
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector /> <RecoveryDashboardOverview key={`${refreshTick}-${days}`} externalPeriod={days} hidePeriodSelector />
</div> </div>
) )
} }

View File

@ -0,0 +1,29 @@
import { useNavigate } from 'react-router-dom'
import RecoveryHistoryVizSection from '../history/RecoveryHistoryVizSection'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
import { normalizeRecoveryHistoryVizConfig } from '../../widgetSystem/recoveryHistoryVizConfig'
/**
* Verlauf Erholung als Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b), Umfang über Layout-Config.
* @param {{ refreshTick?: number, recoveryHistoryVizConfig?: Record<string, unknown> }} props
*/
export default function RecoveryHistoryVizWidget({ refreshTick = 0, recoveryHistoryVizConfig }) {
const nav = useNavigate()
const cfg = normalizeRecoveryHistoryVizConfig(recoveryHistoryVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
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 (Verlauf-Bundle)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>recovery-dashboard-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'activity' } })}>
Verlauf
</button>
</div>
<RecoveryHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
</div>
)
}

View File

@ -0,0 +1,589 @@
import { useState, useEffect } from 'react'
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
ReferenceLine,
ComposedChart,
} from 'recharts'
import { useNavigate } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
import { api } from '../../utils/api'
import { getStatusColor } from '../../utils/interpret'
import KpiTilesOverview from '../KpiTilesOverview'
import {
BODY_HISTORY_VIZ_HISTORY_FULL,
filterBodyHistoryKpiTiles,
} from '../../widgetSystem/bodyHistoryVizConfig'
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome'
dayjs.locale('de')
const fmtDate = (d) => dayjs(d).format('DD.MM')
/** Recharts: in schmalen Flex-Spalten sonst Breite 0. */
function ChartFrame({ heightPx, children }) {
return (
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="body-history-viz__chart-frame">
{children}
</div>
)
}
function verdictShort(status) {
if (status === 'good') return 'Gut'
if (status === 'warn') return 'Hinweis'
return 'Achtung'
}
/** KPI-Kacheln aus Summary + Interpretationstiles — Trend aus Bundle ``weight.trend_kpi`` (Layer 2b). */
function buildBodyKpiTiles({
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
}) {
const tiles = []
if (summary.weight_kg != null) {
const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' }
const trendBits = trendPeriods.length
? trendPeriods.map((t) => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
: ''
const hoverBody = [
'Gewicht im gewählten Zeitraum (letzter Messwert).',
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
minW != null && maxW != null ? `Min. / Max.: ${minW} ${maxW} kg` : null,
trendBits ? `Änderung: ${trendBits}` : null,
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
].filter(Boolean).join('\n')
tiles.push({
key: 'weight',
category: 'Gewicht',
icon: '⚖️',
value: `${summary.weight_kg} kg`,
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
verdict: wt.verdict,
status: wt.status,
hoverTop: 'Gewicht',
hoverBody,
keys: ['weight_aktuell', 'weight_trend'],
})
}
const kfRule = rules.find((r) => r.category === 'Körperfett')
if (summary.body_fat_pct != null) {
tiles.push({
key: 'bf',
category: 'Körperfett',
icon: '🫧',
value: `${summary.body_fat_pct}%`,
valueColor: kfRule ? getStatusColor(kfRule.status) : undefined,
sublabel: summary.bf_category_label || '',
verdict: verdictShort(kfRule?.status || 'good'),
status: kfRule?.status || 'good',
hoverTop: kfRule?.title || 'Körperfettanteil',
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const mmRule = rules.find((r) => r.category === 'Muskelmasse')
if (summary.lean_mass_kg != null || summary.ffmi != null) {
const valParts = []
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
tiles.push({
key: 'lean_ffmi',
category: 'Magermasse',
icon: '💪',
value: valParts.join(' · ') || '—',
sublabel: 'Lean / FFMI',
verdict: mmRule ? verdictShort(mmRule.status) : '—',
status: mmRule?.status || 'good',
hoverTop: mmRule?.title || 'Muskelmasse',
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const bmiRule = rules.find((r) => r.category === 'BMI')
if (bmiRule) {
tiles.push({
key: 'bmi',
category: 'BMI',
icon: '📋',
value: bmiRule.value || '—',
sublabel: 'Body-Mass-Index',
verdict: verdictShort(bmiRule.status),
status: bmiRule.status,
hoverTop: bmiRule.title,
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whrRule = rules.find((r) => r.category === 'Fettverteilung')
if (summary.whr != null && whrRule) {
tiles.push({
key: 'whr',
category: 'Fettverteilung',
icon: '📐',
value: String(summary.whr),
sublabel: 'WHR · Taille ÷ Hüfte',
verdict: verdictShort(whrRule.status),
status: whrRule.status,
hoverTop: whrRule.title || 'Waist-Hip-Ratio',
hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const whtrRule = rules.find((r) => r.category === 'Taille/Größe')
if (summary.whtr != null && whtrRule) {
tiles.push({
key: 'whtr',
category: 'Taille/Größe',
icon: '📏',
value: String(summary.whtr),
sublabel: 'WHtR · Taille ÷ Größe',
verdict: verdictShort(whtrRule.status),
status: whtrRule.status,
hoverTop: whtrRule.title || 'Waist-to-Height-Ratio',
hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
const lastRule = rules.find((r) => r.category.startsWith('Seit letzter'))
if (lastRule) {
tiles.push({
key: 'delta',
category: 'Messvergleich',
icon: '📊',
value: lastRule.value || '—',
sublabel: 'seit Vorperiode',
verdict: verdictShort(lastRule.status),
status: lastRule.status,
hoverTop: lastRule.title,
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
})
}
return tiles
}
function BodyGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.body || []).filter((g) => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map((g) => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text2)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{g.name || g.label_de || g.goal_type}
</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}}
/>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
/**
* Verlauf Körper: nur GET /api/charts/body-history-viz (Layer 2b).
* @param {object} props
* @param {object} props.profile aktives Profil (Zielgewicht-Fallback)
* @param {number} [props.externalPeriod] festes Fenster (Dashboard); sonst interner Zeitraum + PeriodSelector
* @param {import('react').ReactNode} [props.footer] z. B. KI-InsightBox im Verlauf
* @param {boolean} [props.embedded] true im Dashboard-Widget: keine große Section-Überschrift (Karte hat eigenen Titel)
* @param {object} [props.visibility] Sichtbarkeit (Dashboard-Config); fehlt wie Verlauf (alles an, kpi_detail full)
*/
export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false, visibility }) {
const display = visibility === undefined ? BODY_HISTORY_VIZ_HISTORY_FULL : visibility
const chartHMain = embedded ? 176 : 200
const chartHSecondary = embedded ? 152 : 170
const chartHIndex = embedded ? 160 : 180
const chartHFallback = embedded ? 140 : 150
const [internalPeriod, setInternalPeriod] = useState(90)
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
const showPeriodSelector = externalPeriod === undefined
const [groupedGoals, setGroupedGoals] = useState(null)
const [viz, setViz] = useState(null)
const [vizLoading, setVizLoading] = useState(true)
const [vizError, setVizError] = useState(null)
useEffect(() => {
if (!display.show_goals_strip) {
setGroupedGoals({})
return undefined
}
let cancelled = false
api.listGoalsGrouped()
.then((g) => {
if (!cancelled) setGroupedGoals(g)
})
.catch(() => {
if (!cancelled) setGroupedGoals({})
})
return () => {
cancelled = true
}
}, [display.show_goals_strip])
useEffect(() => {
let cancelled = false
setVizLoading(true)
setVizError(null)
api.getBodyHistoryViz(period)
.then((data) => {
if (!cancelled) {
setViz(data)
setVizLoading(false)
}
})
.catch((e) => {
if (!cancelled) {
setVizError(e.message || 'Laden fehlgeschlagen')
setVizLoading(false)
}
})
return () => {
cancelled = true
}
}, [period])
const w = viz?.weight
const cal = viz?.caliper
const circ = viz?.circumference
const summary = viz?.summary || {}
const wCd = (w?.series || []).map((row) => ({
date: fmtDate(row.date),
weight: row.weight,
avg7: row.avg7,
avg14: row.avg14,
}))
const hasWeight = (w?.data_points || 0) >= 2
const avgAll = w?.overall_avg_kg
const minW = w?.min_kg
const maxW = w?.max_kg
const trendPeriods = w?.trend_periods || []
const bfCd = (cal?.series || []).map((s) => ({
date: fmtDate(s.date),
bf: s.body_fat_pct,
}))
const propChartData = (circ?.proportion_series || []).map((p) => ({
date: fmtDate(p.date),
vTaper: p.v_taper_cm,
vTaper_avg: p.v_taper_cm_avg,
belly: p.belly_cm,
}))
const showBellyOnProp = propChartData.some((d) => d.belly != null && d.belly !== undefined)
const idxSeriesRaw = circ?.index_series || []
const idxSeries = idxSeriesRaw.map((row) => ({ ...row, date: fmtDate(row.date) }))
const idxOk = circ?.index_usable
const cirCd = (circ?.fallback_multiline || []).map((r) => ({
date: fmtDate(r.date),
waist: r.waist,
hip: r.hip,
belly: r.belly,
}))
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
const rules = (viz?.interpretation_tiles || []).map((t) => ({
category: t.category,
icon: t.icon,
status: t.status,
title: t.title,
detail: t.detail,
value: t.value,
related_placeholder_keys: t.related_placeholder_keys,
}))
const kpiTiles = buildBodyKpiTiles({
summary,
rules,
trendPeriods,
minW,
maxW,
avgAll,
dataPoints: w?.data_points,
weightTrendKpi: w?.trend_kpi,
goalW,
})
const kpiTilesShown = display.show_kpis ? filterBodyHistoryKpiTiles(kpiTiles, display.kpi_detail || 'full') : []
const hasAnyData =
(w?.data_points > 0) ||
(cal?.data_points > 0) ||
(cirCd.length > 0)
if (vizLoading && !viz) {
return (
<div>
{!embedded && <SectionHeader title="⚖️ Körper" />}
<div className="empty-state">
<div className="spinner" />
</div>
</div>
)
}
if (vizError) {
return (
<div>
{!embedded && <SectionHeader title="⚖️ Körper" />}
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
</div>
)
}
if (!hasAnyData) {
return (
<div>
{!embedded && <SectionHeader title="⚖️ Körper" />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
</div>
)
}
return (
<div>
{!embedded && <SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />}
{embedded && viz?.last_updated && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
</div>
)}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
{display.show_goals_strip && <BodyGoalsStrip grouped={groupedGoals} />}
{display.show_intro_blurb && (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf Fitness</strong>.
</p>
)}
{display.show_layer_meta && viz?.meta?.layer_2a_alignment && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
{viz.meta.layer_2a_alignment}
</div>
)}
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
{vizLoading && <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>}
{display.show_weight_chart && hasWeight && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
Gewicht · {w?.data_points || 0} Einträge
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
Daten <ChevronRight size={10} />
</button>
</div>
<ChartFrame heightPx={chartHMain}>
<ResponsiveContainer width="100%" height="100%">
<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 != null && (
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
)}
{goalW != null && (
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}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', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} 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>
</ChartFrame>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
<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' }} />Ø Gesamt</span>
</div>
</div>
)}
{display.show_body_fat_chart && bfCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
<NavToCaliper />
</div>
<ChartFrame heightPx={chartHSecondary}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={bfCd} 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} />
<YAxis 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) => [`${v}%`, 'KF%']} />
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% zweite Kurve entfällt.</div>
</div>
)}
{display.show_proportion_chart && propChartData.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
<strong>V-Taper (Brust Taille)</strong> in cm.
{showBellyOnProp && (
<>
<strong> Bauch</strong> (rechte Achse).
</>
)}
</div>
</div>
<NavToCircum />
</div>
<ChartFrame heightPx={chartHMain}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{showBellyOnProp && <YAxis yAxisId="belly" 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) => {
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust Taille']
if (name === 'belly') return [`${v} cm`, 'Bauch']
return [v, name]
}}
/>
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
</ComposedChart>
</ResponsiveContainer>
</ChartFrame>
<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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust Taille</span>
<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 4" /></svg></span>gleitender Mittelwert</span>
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
</div>
</div>
)}
{display.show_circumference_index_chart && idxOk && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
</div>
<NavToCircum />
</div>
<ChartFrame heightPx={chartHIndex}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={idxSeries} 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} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
{idxSeries.some((d) => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
{idxSeries.some((d) => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
{idxSeries.some((d) => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
</LineChart>
</ResponsiveContainer>
</ChartFrame>
<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: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
</div>
</div>
)}
{display.show_circumference_lines_chart && propChartData.length < 2 && cirCd.length >= 2 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
<NavToCircum />
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
<ChartFrame heightPx={chartHFallback}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={cirCd} 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} />
<YAxis 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} cm`, n]} />
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
{cirCd.some((d) => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
</LineChart>
</ResponsiveContainer>
</ChartFrame>
</div>
)}
{footer}
</div>
)
}

View File

@ -0,0 +1,33 @@
import FitnessDashboardOverview from '../FitnessDashboardOverview'
/**
* Verlauf Fitness bzw. Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b).
* @param {number} [props.externalPeriod] Widget: feste Tage (790)
* @param {number} [props.period] Verlauf: gesteuerter Zeitraum (inkl. 9999)
* @param {(n: number) => void} [props.onPeriodChange]
* @param {boolean} [props.hidePeriodSelector]
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] undefined = volle Übersicht (wie bisher)
* @param {import('react').ReactNode} [props.footer]
*/
export default function FitnessHistoryVizSection({
externalPeriod,
period,
onPeriodChange,
hidePeriodSelector,
embedded,
visibility,
footer,
}) {
return (
<FitnessDashboardOverview
period={period}
onPeriodChange={onPeriodChange}
hidePeriodSelector={hidePeriodSelector}
externalPeriod={externalPeriod}
embedded={embedded}
visibility={visibility}
footer={footer}
/>
)
}

View File

@ -0,0 +1,535 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
ReferenceLine,
ComposedChart,
ScatterChart,
Scatter,
Line,
Cell,
} from 'recharts'
import { api } from '../../utils/api'
import { getStatusColor, getStatusBg } from '../../utils/interpret'
import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome'
import { HISTORY_OVERVIEW_VIZ_PAGE_FULL, normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig'
function overviewSectionTone(sec) {
const kpis = sec.kpi_short || []
if (kpis.some((k) => k.status === 'bad')) return 'bad'
if (kpis.some((k) => k.status === 'warn')) return 'warn'
const interp = sec.interpretation_short || []
if (interp.some((x) => x.status === 'bad')) return 'bad'
if (interp.some((x) => x.status === 'warn')) return 'warn'
const heur = sec.heuristic_short || []
if (heur.some((h) => h.status === 'warn')) return 'warn'
return 'good'
}
function overviewConfidenceUi(conf) {
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
}
function chartJsScatterPoints(payload) {
const raw = payload?.data?.datasets?.[0]?.data || []
if (!Array.isArray(raw)) return []
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
}
function lagDetailsToCurve(meta) {
let ld = meta?.lag_details
if (!Array.isArray(ld) || ld.length === 0) {
const m = String(meta?.metric || '').toUpperCase()
if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv
else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr
else {
const h = meta?.lag_details_hrv
const r = meta?.lag_details_rhr
const hl = Array.isArray(h) ? h.length : 0
const rl = Array.isArray(r) ? r.length : 0
if (hl >= rl && hl > 0) ld = h
else if (rl > 0) ld = r
else ld = []
}
}
if (!Array.isArray(ld) || ld.length === 0) return []
return ld
.map((d) => ({
lag: Number(d?.lag),
r: d?.r == null || d?.r === '' ? null : Number(d.r),
n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null,
}))
.filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r))
.sort((a, b) => a.lag - b.lag)
}
function driverBarFromStatus(st) {
const s = String(st || '').toLowerCase()
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
return { v: 0.15, fill: '#6B7280' }
}
function chartJsBarRows(payload, fallbackDrivers) {
const labels = payload?.data?.labels || []
const values = payload?.data?.datasets?.[0]?.data || []
const colors = payload?.data?.datasets?.[0]?.backgroundColor
if (labels.length && values.length) {
return labels.map((name, i) => ({
name: name.length > 42 ? `${name.slice(0, 40)}` : name,
value: Number(values[i]),
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
}))
}
if (fallbackDrivers?.length) {
return fallbackDrivers.map((d) => {
const { v, fill } = driverBarFromStatus(d.status)
return {
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}` : String(d.factor || '—'),
value: v,
fill,
subtitle: d.reason,
}
})
}
return []
}
function CorrelationScatterTile({ title, accent, payload }) {
const meta = payload?.metadata || {}
const pts = chartJsScatterPoints(payload)
const curve = lagDetailsToCurve(meta)
const hasChart = pts.length > 0 && meta.correlation != null
const r = Number(meta.correlation)
const strength =
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null
const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28
return (
<div
className="card"
style={{
marginBottom: 0,
padding: 10,
borderLeft: `4px solid ${getStatusColor(strength)}`,
}}
>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
{meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''}
{meta.metric ? ` · ${meta.metric}` : ''}
{meta.confidence ? ` · ${meta.confidence}` : ''}
</div>
{!hasChart ? (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: curve.length ? 8 : 0 }}>
{meta.message || 'Keine Daten für diese Korrelation.'}
</div>
{curve.length > 0 && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 6 }}>
Lag-Sweep (kein Lag mit 15 Paaren): r über Lags nur zur Einordnung.
</div>
)}
{curve.length > 0 && (
<ResponsiveContainer width="100%" height={120}>
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} label={{ value: 'Lag (T)', fontSize: 9, fill: 'var(--text3)', offset: -2 }} />
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} label={{ value: 'r', fontSize: 9, fill: 'var(--text3)', angle: -90 }} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
/>
<Line type="monotone" dataKey="r" stroke={accent} strokeWidth={2} dot={{ r: 3, fill: accent }} isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
)}
</>
) : curve.length >= 1 ? (
<>
<div style={{ fontSize: 9, color: 'var(--text3)', marginBottom: 4 }}>
Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag.
</div>
<ResponsiveContainer width="100%" height={132}>
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
/>
<Line
type="monotone"
dataKey="r"
stroke={accent}
strokeWidth={2}
isAnimationActive={false}
dot={(props) => {
const { cx, cy, payload: pl } = props
if (cx == null || cy == null || !pl) return null
const isBest = bestLag != null && Number(pl.lag) === bestLag
return (
<circle
cx={cx}
cy={cy}
r={isBest ? 6 : 3.5}
fill={isBest ? 'var(--surface)' : accent}
stroke={isBest ? accent : 'none'}
strokeWidth={isBest ? 2.5 : 0}
/>
)
}}
/>
</ComposedChart>
</ResponsiveContainer>
</>
) : (
<ResponsiveContainer width="100%" height={118}>
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
<Scatter name="r" data={pts} fill={accent} />
</ScatterChart>
</ResponsiveContainer>
)}
{meta.interpretation ? (
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
) : null}
</div>
)
}
function DriversImpactTile({ payload, driversFallback }) {
const meta = payload?.metadata || {}
const rows = chartJsBarRows(payload, driversFallback)
if (!rows.length) {
return (
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
</div>
)
}
const h = Math.min(220, Math.max(96, rows.length * 34))
return (
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
<ResponsiveContainer width="100%" height={h}>
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
<Tooltip
content={({ active, payload: pp }) => {
if (!active || !pp?.length) return null
const p = pp[0].payload
return (
<div
style={{
background: 'var(--surface)',
border: '1px solid var(--border)',
padding: '8px 10px',
borderRadius: 8,
fontSize: 11,
maxWidth: 280,
}}
>
<div style={{ fontWeight: 600 }}>{p.name}</div>
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
</div>
)
}}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{rows.map((e, i) => (
<Cell key={i} fill={e.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)
}
/**
* Verlauf «Gesamt» / Dashboard-Widget: Layer-2b history-overview-viz (+ chart_payloads C1C4).
*
* @param {object} props
* @param {import('react').ReactNode} [props.footer]
* @param {number} [props.externalPeriod] feste Tage (Widget); sonst interner PeriodSelector (309999)
* @param {boolean} [props.hidePeriodSelector]
* @param {boolean} [props.embedded]
* @param {Record<string, unknown>} [props.visibility] normalisierte Widget-Config; undefined = Verlauf volle Ansicht
*/
export default function HistoryOverviewVizSection({
footer = null,
externalPeriod,
hidePeriodSelector = false,
embedded = false,
visibility: visibilityProp,
}) {
const navigate = useNavigate()
const [period, setPeriod] = useState(30)
const [bundle, setBundle] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
const effPeriod = externalPeriod != null ? externalPeriod : period
const daysReq = effPeriod === 9999 ? 3650 : effPeriod
useEffect(() => {
let cancelled = false
setLoading(true)
const attachCharts = (overview, c1, c2, c3, c4) => {
if (!cancelled) {
setBundle({ overview, chartC1: c1, chartC2: c2, chartC3: c3, chartC4: c4 })
setErr(null)
}
}
const run = async () => {
try {
const overview = await api.getHistoryOverviewViz(daysReq)
const cp = overview?.chart_payloads
if (cp && cp.c1_weight_energy != null && cp.c2_protein_lbm != null && cp.c3_load_vitals != null && cp.c4_recovery_performance != null) {
attachCharts(overview, cp.c1_weight_energy, cp.c2_protein_lbm, cp.c3_load_vitals, cp.c4_recovery_performance)
} else {
const [chartC1, chartC2, chartC3, chartC4] = await Promise.all([
api.getWeightEnergyCorrelationChart(14),
api.getLbmProteinCorrelationChart(14),
api.getLoadVitalsCorrelationChart(14),
api.getRecoveryPerformanceChart(),
])
attachCharts(overview, chartC1, chartC2, chartC3, chartC4)
}
} catch (e) {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
} finally {
if (!cancelled) setLoading(false)
}
}
run()
return () => {
cancelled = true
}
}, [daysReq])
if (loading) {
return (
<div>
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div>
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{err}</div>
</div>
)
}
const data = bundle?.overview
const chartC1 = bundle?.chartC1
const chartC2 = bundle?.chartC2
const chartC3 = bundle?.chartC3
const chartC4 = bundle?.chartC4
const lag = data?.lag_correlations || {}
const c4drivers = lag.recovery_performance?.drivers || []
const sections = data?.sections || []
const confUi = overviewConfidenceUi(data?.confidence)
const vis =
visibilityProp != null ? normalizeHistoryOverviewVizConfig(visibilityProp) : HISTORY_OVERVIEW_VIZ_PAGE_FULL
const sectionTileEnabled = (id) => {
if (id === 'body') return vis.show_section_body
if (id === 'nutrition') return vis.show_section_nutrition
if (id === 'fitness') return vis.show_section_fitness
if (id === 'recovery') return vis.show_section_recovery
return true
}
const wantsAnySectionTile =
vis.show_section_body ||
vis.show_section_nutrition ||
vis.show_section_fitness ||
vis.show_section_recovery
const visibleSections = wantsAnySectionTile ? sections.filter((s) => sectionTileEnabled(s.id)) : []
return (
<div>
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
{vis.show_confidence_banner && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 10,
marginBottom: 14,
padding: '10px 12px',
borderRadius: 12,
border: '1px solid var(--border)',
background: getStatusBg(confUi.tone),
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
}}
>
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
</div>
</div>
)}
{vis.show_intro_blurb && (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
Ernährung
</button>
. Die Kacheln C1C4 entsprechen denselben Chart.js-Payloads wie <code style={{ fontSize: 10 }}>/api/charts/*</code> (bei aktuellem Backend im Overview-Bundle enthalten).
</p>
)}
{wantsAnySectionTile && (visibleSections.length === 0 ? (
<EmptySection text="Keine Bereichsdaten." />
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
{visibleSections.map((sec) => {
const tone = overviewSectionTone(sec)
const stripe = getStatusColor(tone)
const badgeBg = getStatusBg(tone)
return (
<div
key={sec.id}
style={{
borderRadius: 12,
border: '1px solid var(--border)',
borderLeft: `5px solid ${stripe}`,
background: 'var(--surface)',
padding: '12px 12px 12px 14px',
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 30,
height: 30,
borderRadius: 10,
fontSize: 13,
fontWeight: 800,
color: stripe,
background: badgeBg,
}}
>
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
</span>
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
</div>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
>
Öffnen
</button>
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
{(sec.kpi_short || []).length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
{(sec.kpi_short || []).map((k, i) => (
<div
key={i}
style={{
padding: '8px 10px',
borderRadius: 10,
background: getStatusBg(k.status || 'good'),
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
}}
>
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
</div>
))}
</div>
)}
{(sec.interpretation_short || []).map((it, i) => (
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
</div>
))}
{(sec.heuristic_short || []).map((h, i) => (
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
</div>
))}
{(sec.insights_short || []).map((ins, i) => (
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
<strong>{ins.title}</strong>
<div>{ins.body}</div>
</div>
))}
</div>
)
})}
</div>
))}
{vis.show_correlation_c1_c3 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (C1C3)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10 }}>
<CorrelationScatterTile title="C1 Energiebilanz ↔ Gewicht" accent="#1D9E75" payload={chartC1} />
<CorrelationScatterTile title="C2 Protein ↔ Magermasse" accent="#3B82F6" payload={chartC2} />
<CorrelationScatterTile title="C3 Last ↔ HRV/RHR" accent="#F59E0B" payload={chartC3} />
</div>
</>
)}
{vis.show_drivers_c4 && (
<>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
</>
)}
{footer}
</div>
)
}

View File

@ -0,0 +1,608 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
ReferenceLine,
BarChart,
Bar,
PieChart,
Pie,
Cell,
} from 'recharts'
import { ChevronRight } from 'lucide-react'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
import { api } from '../../utils/api'
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../../utils/macroChartTheme'
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../NutritionCharts'
import KpiTilesOverview from '../KpiTilesOverview'
import {
NUTRITION_HISTORY_VIZ_HISTORY_FULL,
filterNutritionHistoryKpiTiles,
} from '../../widgetSystem/nutritionHistoryVizConfig'
import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome'
dayjs.locale('de')
const fmtDate = (d) => dayjs(d).format('DD.MM')
function ChartFrame({ heightPx, children }) {
return (
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="nutrition-history-viz__chart-frame">
{children}
</div>
)
}
function NutritionGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.nutrition || []).filter((g) => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map((g) => (
<div
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text2)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{g.name || g.label_de || g.goal_type}
</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div
style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}}
/>
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
))}
</div>
</div>
)
}
function kcalVsWeightKcalDomain(points, tdeeRef) {
const vals = (points || [])
.map((d) => Number(d.kcal_avg))
.filter((v) => !Number.isNaN(v))
if (!vals.length) return ['auto', 'auto']
let lo = Math.min(...vals)
let hi = Math.max(...vals)
const t = tdeeRef != null ? Number(tdeeRef) : NaN
if (!Number.isNaN(t)) {
lo = Math.min(lo, t)
hi = Math.max(hi, t)
}
const span = hi - lo || 400
const pad = Math.max(100, span * 0.1)
return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)]
}
const TDEE_REF_LINE_COLOR = '#475569'
function KcalVsWeightLegend({ showTdee }) {
const line = (color) => ({
display: 'inline-block',
width: 22,
height: 3,
background: color,
borderRadius: 1,
verticalAlign: 'middle',
marginRight: 6,
})
return (
<div
className="kcal-vs-weight-legend"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: '12px 18px',
marginTop: 10,
fontSize: 10,
color: 'var(--text2)',
lineHeight: 1.35,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={line('#EA580C')} />
Ø Kalorien (7-Tage-Mittel)
</span>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
borderRadius: '50%',
background: '#2563EB',
marginRight: 6,
verticalAlign: 'middle',
}}
/>
Gewicht (kg)
</span>
{showTdee ? (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 22,
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
TDEE-Referenz (geschätzt)
</span>
) : null}
</div>
)
}
/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics). */
function KcalVsWeightChart({ vizKcalWeight, chartHeight = 200 }) {
const n = vizKcalWeight?.points?.length ?? 0
if (n < 5) {
if (n === 0) return null
return (
<div className="card" style={{ marginBottom: 12, padding: '12px 14px' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45 }}>
Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
</div>
</div>
)
}
const tdee = vizKcalWeight.tdee_reference_kcal
const kcalVsW = vizKcalWeight.points.map((d) => ({
...d,
date: fmtDate(d.date),
}))
const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<ChartFrame heightPx={chartHeight}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
<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) => [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
/>
{tdeeLabel != null && (
<ReferenceLine
yAxisId="kcal"
y={tdeeLabel}
stroke={TDEE_REF_LINE_COLOR}
strokeDasharray="6 5"
strokeWidth={2}
isFront
/>
)}
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
{tdeeLabel != null
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
</div>
</div>
)
}
/**
* Verlauf Ernährung: GET /charts/nutrition-history-viz (Layer 2b) + optional NutritionCharts mit Bundle-Payloads.
* @param {object} [props.visibility] Dashboard-Config; undefined = voller Verlauf
* @param {number} [props.externalPeriod] feste Tage (790) im Widget
* @param {boolean} [props.embedded]
* @param {import('react').ReactNode} [props.footer]
*/
export default function NutritionHistoryVizSection({ externalPeriod, embedded = false, visibility, footer = null }) {
const display = visibility === undefined ? NUTRITION_HISTORY_VIZ_HISTORY_FULL : visibility
const chartHMain = embedded ? 176 : 200
const chartHBal = embedded ? 160 : 180
const chartHPlm = embedded ? 160 : 180
const [internalPeriod, setInternalPeriod] = useState(30)
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
const showPeriodSelector = externalPeriod === undefined
const [groupedGoals, setGroupedGoals] = useState(null)
const [viz, setViz] = useState(null)
const [vizLoad, setVizLoad] = useState(true)
const [vizErr, setVizErr] = useState(null)
useEffect(() => {
if (!display.show_goals_strip) {
setGroupedGoals({})
return undefined
}
let cancelled = false
api.listGoalsGrouped()
.then((g) => {
if (!cancelled) setGroupedGoals(g)
})
.catch(() => {
if (!cancelled) setGroupedGoals({})
})
return () => {
cancelled = true
}
}, [display.show_goals_strip])
useEffect(() => {
let cancelled = false
setViz(null)
setVizLoad(true)
setVizErr(null)
const daysReq = period === 9999 ? 9999 : period
api.getNutritionHistoryViz(daysReq)
.then((v) => {
if (!cancelled) setViz(v)
})
.catch((e) => {
if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen')
})
.finally(() => {
if (!cancelled) setVizLoad(false)
})
return () => {
cancelled = true
}
}, [period])
if (vizLoad) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (vizErr) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
</div>
)
}
if (!viz?.has_nutrition_entries) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren" />
</div>
)
}
const summary = viz.summary || {}
const n = Math.max(0, Number(summary.data_points) || 0)
const avgKcal = Math.round(Number(summary.kcal_avg) || 0)
const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0)
const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period)
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
...t,
sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}` : t.sublabel,
}))
const kpiTilesShown = display.show_kpis
? filterNutritionHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
: []
const pieData = viz.donut_avg_pct || []
const cdMacro = (viz.daily_macros || []).map((d) => ({
date: fmtDate(d.date),
Protein: d.Protein,
KH: d.KH,
Fett: d.Fett,
kcal: d.kcal,
}))
const weeklyMacro = viz.weekly_macro_chart
const balDaily = viz.calorie_balance_daily || []
const plm = viz.protein_vs_lean_mass || {}
const plmPts = plm.points || []
const nutHeur = viz.nutrition_correlation_heuristics || []
const tdeeRef = viz.tdee_reference_kcal
if (!cdMacro.length || n === 0) {
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
<EmptySection text="Keine Einträge im gewählten Zeitraum." />
</div>
)
}
return (
<div>
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
{embedded && viz?.last_updated && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
</div>
)}
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
{display.show_intro_blurb && (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
{' '}
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
<strong>«Kurz-Einordnung»</strong> finden Sie hier früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
</p>
)}
{display.show_goals_strip && <NutritionGoalsStrip grouped={groupedGoals} />}
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
{display.show_kcal_vs_weight && <KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} chartHeight={chartHMain} />}
{display.show_calorie_balance_chart && balDaily.length > 0 && tdeeRef != null && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorienbilanz (Aufnahme TDEE ~{Math.round(tdeeRef)} kcal)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tagesbilanz und 7-Tage-Mittel gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
</div>
<ChartFrame heightPx={chartHBal}>
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v > 0 ? '+' : ''}${v} kcal`, name === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
/>
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
</div>
)}
{display.show_protein_lean_chart && plmPts.length >= 3 && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Protein vs. Magermasse (Caliper, forward-filled)
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
</div>
<ChartFrame heightPx={chartHPlm}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
{plm.protein_target_low_g > 0 && (
<ReferenceLine
yAxisId="prot"
y={plm.protein_target_low_g}
stroke="#1D9E75"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
/>
)}
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v}${name === 'protein' ? 'g' : ' kg'}`, name === 'protein' ? 'Protein' : 'Mager']}
/>
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
</LineChart>
</ResponsiveContainer>
</ChartFrame>
</div>
)}
{display.show_heuristics && nutHeur.length > 0 && (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung Kurz-Einordnung</div>
{nutHeur.map((item, i) => (
<div
key={i}
style={{
padding: '10px 12px',
borderRadius: 8,
marginBottom: 6,
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
</div>
</div>
</div>
))}
</div>
)}
{display.show_macro_daily_bars && (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ChartFrame heightPx={chartHMain}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartFrame>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
</div>
</div>
)}
{display.show_macro_distribution_pair && (
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
{pieData.length > 0 ? (
<div className="nutrition-macro-pair__donut-inner">
<div className="nutrition-macro-pair__donut-chart">
<ChartFrame heightPx={NUTRITION_MACRO_CHART_BLOCK_PX}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius="38%"
outerRadius="58%"
dataKey="value"
startAngle={90}
endAngle={-270}
paddingAngle={1}
>
{pieData.map((e, i) => (
<Cell key={i} fill={macroFillByName(e.name)} />
))}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
</ResponsiveContainer>
</ChartFrame>
</div>
<div className="nutrition-macro-pair__legend">
{pieData.map((p) => {
const fill = macroFillByName(p.name)
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{p.grams != null ? `${p.grams}g` : '—'}
</div>
</div>
)
})}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
<div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={false} error={null} />
</div>
</div>
)}
{display.show_energy_protein_charts && (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts
days={chartDays}
showWeeklyMacroDistribution={false}
hideEnergyAvailabilityCard
prefetchedChartPayloads={viz.chart_payloads}
/>
</>
)}
{footer}
</div>
)
}

View File

@ -0,0 +1,26 @@
import RecoveryDashboardOverview from '../RecoveryDashboardOverview'
/**
* Verlauf Erholung bzw. Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b).
*/
export default function RecoveryHistoryVizSection({
externalPeriod,
period,
onPeriodChange,
hidePeriodSelector,
embedded,
visibility,
footer,
}) {
return (
<RecoveryDashboardOverview
period={period}
onPeriodChange={onPeriodChange}
hidePeriodSelector={hidePeriodSelector}
externalPeriod={externalPeriod}
embedded={embedded}
visibility={visibility}
footer={footer}
/>
)
}

View File

@ -0,0 +1,87 @@
import { useNavigate } from 'react-router-dom'
import { ChevronRight } from 'lucide-react'
import dayjs from 'dayjs'
export function NavToCaliper() {
const nav = useNavigate()
return (
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/caliper')}>
Caliper-Daten <ChevronRight size={10} />
</button>
)
}
export function NavToCircum() {
const nav = useNavigate()
return (
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/circum')}>
Umfang-Daten <ChevronRight size={10} />
</button>
)
}
export function EmptySection({ text, to, toLabel }) {
const nav = useNavigate()
return (
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
<div style={{ marginBottom: 12 }}>{text}</div>
{to && (
<button type="button" className="btn btn-primary" onClick={() => nav(to)}>
{toLabel || 'Daten erfassen'}
</button>
)}
</div>
)
}
export function SectionHeader({ title, to, toLabel, lastUpdated }) {
const nav = useNavigate()
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>{title}</h2>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{lastUpdated && <span style={{ fontSize: 11, color: 'var(--text3)' }}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
{to && (
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '5px 10px' }} onClick={() => nav(to)}>
{toLabel || 'Daten'} <ChevronRight size={12} />
</button>
)}
</div>
</div>
)
}
export function PeriodSelector({ value, onChange }) {
const opts = [
{ v: 30, l: '30 Tage' },
{ v: 90, l: '90 Tage' },
{ v: 180, l: '6 Monate' },
{ v: 365, l: '1 Jahr' },
{ v: 9999, l: 'Alles' },
]
return (
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
{opts.map((o) => (
<button
key={o.v}
type="button"
onClick={() => onChange(o.v)}
style={{
padding: '4px 10px',
borderRadius: 12,
fontSize: 11,
fontWeight: 500,
border: '1.5px solid',
cursor: 'pointer',
fontFamily: 'var(--font)',
background: value === o.v ? 'var(--accent)' : 'transparent',
borderColor: value === o.v ? 'var(--accent)' : 'var(--border2)',
color: value === o.v ? 'white' : 'var(--text2)',
}}
>
{o.l}
</button>
))}
</div>
)
}

View File

@ -11,6 +11,11 @@ import {
} from '../widgetSystem/bodyChartDays' } from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import { import {
moveWidget, moveWidget,
moveWidgetToIndex, moveWidgetToIndex,
@ -20,8 +25,13 @@ import {
const CHART_DAYS_WIDGET_IDS = new Set([ const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview', 'body_overview',
'body_history_viz',
'activity_overview', 'activity_overview',
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -495,6 +505,91 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
/> />
</div> </div>
)} )}
{w.id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'fitness_history_viz' && (
<FitnessHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'history_overview_viz' && (
<HistoryOverviewVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

View File

@ -12,13 +12,23 @@ import {
} from '../widgetSystem/bodyChartDays' } from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor' import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor' import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor' import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
/** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */ /** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */
const CHART_DAYS_WIDGET_IDS = new Set([ const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview', 'body_overview',
'body_history_viz',
'activity_overview', 'activity_overview',
'nutrition_detail_charts', 'nutrition_detail_charts',
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel', 'recovery_charts_panel',
]) ])
@ -318,11 +328,21 @@ export default function DashboardLabPage() {
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}> <label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
{w.id === 'body_overview' {w.id === 'body_overview'
? 'Körper-Chart' ? 'Körper-Chart'
: w.id === 'activity_overview' : w.id === 'body_history_viz'
? 'Aktivität (Verteilung & Konsistenz)' ? 'Körper (Verlauf-Bundle)'
: w.id === 'nutrition_detail_charts' : w.id === 'activity_overview'
? 'Ernährung — Charts' ? 'Aktivität (Verteilung & Konsistenz)'
: 'Erholung — Charts'}{' '} : w.id === 'nutrition_detail_charts'
? 'Ernährung — Charts'
: w.id === 'nutrition_history_viz'
? 'Ernährung (Verlauf-Bundle)'
: w.id === 'fitness_history_viz'
? 'Fitness (Verlauf-Bundle)'
: w.id === 'history_overview_viz'
? 'Gesamtübersicht (Verlauf-Bundle)'
: w.id === 'recovery_history_viz'
? 'Erholung (Verlauf-Bundle)'
: 'Erholung — Charts'}{' '}
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX} Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label> </label>
<input <input
@ -334,11 +354,21 @@ export default function DashboardLabPage() {
aria-label={ aria-label={
w.id === 'body_overview' w.id === 'body_overview'
? 'Körper-Chart Zeitraum in Tagen' ? 'Körper-Chart Zeitraum in Tagen'
: w.id === 'activity_overview' : w.id === 'body_history_viz'
? 'Aktivität Zeitraum in Tagen' ? 'Körper Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'nutrition_detail_charts' : w.id === 'activity_overview'
? 'Ernährungs-Charts Zeitraum in Tagen' ? 'Aktivität Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen' : w.id === 'nutrition_detail_charts'
? 'Ernährungs-Charts Zeitraum in Tagen'
: w.id === 'nutrition_history_viz'
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'fitness_history_viz'
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'history_overview_viz'
? 'Gesamtübersicht Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'recovery_history_viz'
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
} }
value={ value={
chartDaysDraftByWidgetId[w.id] !== undefined chartDaysDraftByWidgetId[w.id] !== undefined
@ -374,6 +404,91 @@ export default function DashboardLabPage() {
/> />
</div> </div>
)} )}
{w.id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'nutrition_history_viz' && (
<NutritionHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'fitness_history_viz' && (
<FitnessHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'recovery_history_viz' && (
<RecoveryHistoryVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
{w.id === 'history_overview_viz' && (
<HistoryOverviewVizConfigEditor
config={w.config || {}}
onChange={(next) =>
setLayout((L) =>
normalizeLayoutForEditor({
...L,
widgets: L.widgets.map((x, j) => {
if (j !== i) return x
if (Object.keys(next).length === 0) return { ...x, config: {} }
return { ...x, config: { ...(x.config || {}), ...next } }
}),
})
)
}
/>
)}
</li> </li>
) )
})} })}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,91 @@
import { BODY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
const CHART_TOGGLES = [
{ key: 'show_weight_chart', label: 'Gewichts-Chart' },
{ key: 'show_body_fat_chart', label: 'Körperfett (Caliper)' },
{ key: 'show_proportion_chart', label: 'Silhouette & Proportion' },
{ key: 'show_circumference_index_chart', label: 'Umfänge — Index' },
{ key: 'show_circumference_lines_chart', label: 'Umfänge — Linien (Fallback)' },
]
const OTHER_TOGGLES = [
{ key: 'show_goals_strip', label: 'Körper-Ziele (Strip)' },
{ key: 'show_intro_blurb', label: 'Hinweistext unter Zielen' },
{ key: 'show_layer_meta', label: 'Layer-2a-Hinweis (Meta)' },
{ key: 'show_kpis', label: 'KPI-Kacheln' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function BodyHistoryVizConfigEditor({ config, onChange }) {
const merged = normalizeBodyHistoryVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = BODY_HISTORY_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Körper (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (nur KPI kompakt + Gewicht).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="body_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (Gewicht, KF%, Magermasse)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="body_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (wie Verlauf alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(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={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,89 @@
import { FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
const CHART_TOGGLES = [
{ key: 'show_chart_training_volume', label: 'Trainingsvolumen (Balken)' },
{ key: 'show_chart_training_type_distribution', label: 'Training nach Kategorie (Kuchen)' },
{ key: 'show_chart_quality_sessions', label: 'Qualitäts-Sessions' },
{ key: 'show_chart_load_monitoring', label: 'Belastung / Load-Zeitreihe' },
]
const OTHER_TOGGLES = [
{ key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Issue-53-Hinweis)' },
{ key: 'show_kpis', label: 'KPI-Kacheln' },
{ key: 'show_progress_insights', label: 'Einschätzungen (Progress-Insights)' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function FitnessHistoryVizConfigEditor({ config, onChange }) {
const merged = normalizeFitnessHistoryVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Fitness (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, Volumen + Kategorien).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="fitness_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (erste 4 Kacheln)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="fitness_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(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={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,65 @@
import {
HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS,
normalizeHistoryOverviewVizConfig,
} from './historyOverviewVizConfig'
const SECTION_TOGGLES = [
{ key: 'show_section_body', label: 'Körper' },
{ key: 'show_section_nutrition', label: 'Ernährung' },
{ key: 'show_section_fitness', label: 'Fitness' },
{ key: 'show_section_recovery', label: 'Erholung' },
]
const OTHER_TOGGLES = [
{ key: 'show_confidence_banner', label: 'Banner «Datenlage»' },
{ key: 'show_intro_blurb', label: 'Hinweistext (Ernährung / API)' },
{ key: 'show_correlation_c1_c3', label: 'Lag-Korrelationen C1C3 (Charts)' },
{ key: 'show_drivers_c4', label: 'Einflussfaktoren C4' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function HistoryOverviewVizConfigEditor({ config, onChange }) {
const merged = normalizeHistoryOverviewVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Gesamtübersicht (Verlauf-Bundle):</strong> welche Bereichs-Kacheln und weitere Blöcke erscheinen.
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereichs-Kacheln</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
{SECTION_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Weitere Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,92 @@
import { NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
const CHART_TOGGLES = [
{ key: 'show_kcal_vs_weight', label: 'Kalorien vs. Gewicht' },
{ key: 'show_calorie_balance_chart', label: 'Kalorienbilanz' },
{ key: 'show_protein_lean_chart', label: 'Protein vs. Magermasse' },
{ key: 'show_heuristics', label: 'Kurz-Einordnung (Heuristiken)' },
{ key: 'show_macro_daily_bars', label: 'Makros täglich (Balken)' },
{ key: 'show_macro_distribution_pair', label: 'Donut + Wochen-Makros' },
{ key: 'show_energy_protein_charts', label: 'Zeitverläufe (NutritionCharts, Bundle-Payloads)' },
]
const OTHER_TOGGLES = [
{ key: 'show_goals_strip', label: 'Ernährungs-Ziele (Strip)' },
{ key: 'show_intro_blurb', label: 'Hinweistext (Data-Layer)' },
{ key: 'show_kpis', label: 'KPI-Kacheln' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function NutritionHistoryVizConfigEditor({ config, onChange }) {
const merged = normalizeNutritionHistoryVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Ernährung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, kcal vs. Gewicht, Makro-Balken + Donut/Woche).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="nutrition_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (erste 4 Kacheln)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="nutrition_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (wie Verlauf alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(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={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,106 @@
import { RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
const CHART_TOGGLES = [
{ key: 'show_chart_recovery_score', label: 'HRV-/Recovery-Score-Verlauf' },
{ key: 'show_chart_sleep_quality', label: 'Schlaf: Dauer & Qualität' },
{ key: 'show_chart_sleep_debt', label: 'Schlafschuld' },
{ key: 'show_chart_hrv_rhr', label: 'HRV & Ruhepuls (Zeitverlauf)' },
]
const SECTION_TOGGLES = [
{ key: 'show_sleep_section_heading', label: 'Zwischenüberschrift «Schlaf & Erholung»' },
{ key: 'show_heart_section_heading', label: 'Zwischenüberschrift «Herz & Kreislauf»' },
{ key: 'show_heart_context_card', label: 'Herz: Einordnung, Zonen, Snapshots' },
{ key: 'show_vitals_extra_heading', label: 'Überschrift «Weitere Vitalparameter»' },
{ key: 'show_vitals_extra_trends', label: 'VO2 / SpO2 / Atemfrequenz (Verläufe)' },
]
const OTHER_TOGGLES = [
{ key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Data-Layer)' },
{ key: 'show_kpis', label: 'KPI-Kacheln' },
{ key: 'show_progress_insights', label: 'Überblick: Recovery & Schlaf (Karten)' },
]
/**
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
*/
export default function RecoveryHistoryVizConfigEditor({ config, onChange }) {
const merged = normalizeRecoveryHistoryVizConfig(config)
const patch = (partial) => {
const next = { ...merged, ...partial }
const def = RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS
const stored = {}
for (const k of Object.keys(def)) {
if (next[k] !== def[k]) stored[k] = next[k]
}
onChange(stored)
}
const setBool = (key, checked) => {
patch({ [key]: checked })
}
return (
<div style={{ marginTop: 10, marginLeft: 28 }}>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
<strong>Erholung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
Standard (KPI kompakt, Schlaf-Charts, HRV/RHR ohne Kontextkarte und Extra-Vitals).
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
<input
type="radio"
name="recovery_hist_kpi_detail"
checked={merged.kpi_detail === 'compact'}
onChange={() => patch({ kpi_detail: 'compact' })}
/>
<span>Kompakt (erste 4 Kacheln)</span>
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
<input
type="radio"
name="recovery_hist_kpi_detail"
checked={merged.kpi_detail === 'full'}
onChange={() => patch({ kpi_detail: 'full' })}
/>
<span>Voll (alle Kacheln)</span>
</label>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{OTHER_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Abschnitte</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SECTION_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{CHART_TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(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={() => onChange({})}
>
Auf schlanken Standard zurück
</button>
</div>
)
}

View File

@ -0,0 +1,81 @@
/**
* Sichtbarkeit / Umfang für body_history_viz (sync mit backend dashboard_widget_config).
* `null` / fehlend Verlauf: alles sichtbar (vollständige Parität zur History-Seite).
*/
/** Verlauf-Tab Körper: volle Parität (kein Layout-Config). */
export const BODY_HISTORY_VIZ_HISTORY_FULL = {
chart_days: 90,
show_goals_strip: true,
show_intro_blurb: true,
show_layer_meta: true,
show_kpis: true,
kpi_detail: 'full',
show_weight_chart: true,
show_body_fat_chart: true,
show_proportion_chart: true,
show_circumference_index_chart: true,
show_circumference_lines_chart: true,
}
/** Default für Dashboard-Widget (schlank). */
export const BODY_HISTORY_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_goals_strip: false,
show_intro_blurb: false,
show_layer_meta: false,
show_kpis: true,
kpi_detail: 'compact',
show_weight_chart: true,
show_body_fat_chart: false,
show_proportion_chart: false,
show_circumference_index_chart: false,
show_circumference_lines_chart: false,
}
const BOOL_KEYS = [
'show_goals_strip',
'show_intro_blurb',
'show_layer_meta',
'show_kpis',
'show_weight_chart',
'show_body_fat_chart',
'show_proportion_chart',
'show_circumference_index_chart',
'show_circumference_lines_chart',
]
/**
* @param {Record<string, unknown>|null|undefined} raw aus Layout-Config (Backend liefert nach Save ggf. alle Keys)
* @returns {typeof BODY_HISTORY_VIZ_WIDGET_DEFAULTS}
*/
export function normalizeBodyHistoryVizConfig(raw) {
const base = { ...BODY_HISTORY_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
base.kpi_detail = raw.kpi_detail
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}
const COMPACT_KPI_KEYS = new Set(['weight', 'bf', 'lean_ffmi'])
/**
* @param {Array<{ key: string }>} tiles
* @param {'compact'|'full'} detail
*/
export function filterBodyHistoryKpiTiles(tiles, detail) {
if (detail === 'full' || !Array.isArray(tiles)) return tiles
return tiles.filter((t) => COMPACT_KPI_KEYS.has(t.key))
}

View File

@ -0,0 +1,70 @@
/**
* Sichtbarkeit für fitness_history_viz (sync mit backend dashboard_widget_config).
* `visibility === undefined` Verlauf: alles an (wie bisherige Fitness-Übersicht).
*/
export const FITNESS_HISTORY_VIZ_HISTORY_FULL = {
chart_days: 30,
show_layer_meta: true,
show_kpis: true,
kpi_detail: 'full',
show_progress_insights: true,
show_chart_training_volume: true,
show_chart_training_type_distribution: true,
show_chart_quality_sessions: true,
show_chart_load_monitoring: true,
}
export const FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_layer_meta: false,
show_kpis: true,
kpi_detail: 'compact',
show_progress_insights: false,
show_chart_training_volume: true,
show_chart_training_type_distribution: true,
show_chart_quality_sessions: false,
show_chart_load_monitoring: false,
}
const BOOL_KEYS = [
'show_layer_meta',
'show_kpis',
'show_progress_insights',
'show_chart_training_volume',
'show_chart_training_type_distribution',
'show_chart_quality_sessions',
'show_chart_load_monitoring',
]
/**
* @param {Record<string, unknown>|null|undefined} raw
*/
export function normalizeFitnessHistoryVizConfig(raw) {
const base = { ...FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
base.kpi_detail = raw.kpi_detail
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}
/**
* @param {Array<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterFitnessHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -0,0 +1,75 @@
/**
* Sichtbarkeit für history_overview_viz (sync mit backend dashboard_widget_config).
* `visibility === undefined` Verlauf-Tab: volle Gesamtübersicht (wie bisher).
*/
export const HISTORY_OVERVIEW_VIZ_SECTION_KEYS = [
'show_section_body',
'show_section_nutrition',
'show_section_fitness',
'show_section_recovery',
]
export const HISTORY_OVERVIEW_VIZ_PAGE_FULL = {
chart_days: 30,
show_confidence_banner: true,
show_intro_blurb: true,
show_section_body: true,
show_section_nutrition: true,
show_section_fitness: true,
show_section_recovery: true,
show_correlation_c1_c3: true,
show_drivers_c4: true,
}
export const HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_confidence_banner: true,
show_intro_blurb: true,
show_section_body: true,
show_section_nutrition: true,
show_section_fitness: true,
show_section_recovery: true,
show_correlation_c1_c3: true,
show_drivers_c4: true,
}
const BOOL_KEYS = [
'show_confidence_banner',
'show_intro_blurb',
...HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
'show_correlation_c1_c3',
'show_drivers_c4',
]
function hasExplicitSectionKeys(raw) {
return HISTORY_OVERVIEW_VIZ_SECTION_KEYS.some((k) => Object.prototype.hasOwnProperty.call(raw, k))
}
/**
* @param {Record<string, unknown>|null|undefined} raw
*/
export function normalizeHistoryOverviewVizConfig(raw) {
const base = { ...HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
if (Object.prototype.hasOwnProperty.call(raw, 'show_area_summaries') && !hasExplicitSectionKeys(raw)) {
const v = raw.show_area_summaries === true
for (const k of HISTORY_OVERVIEW_VIZ_SECTION_KEYS) {
base[k] = v
}
}
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}

View File

@ -0,0 +1,79 @@
/**
* Sichtbarkeit für nutrition_history_viz (sync mit backend dashboard_widget_config).
* `visibility === undefined` Verlauf-Tab: alles an.
*/
export const NUTRITION_HISTORY_VIZ_HISTORY_FULL = {
chart_days: 30,
show_goals_strip: true,
show_intro_blurb: true,
show_kpis: true,
kpi_detail: 'full',
show_kcal_vs_weight: true,
show_calorie_balance_chart: true,
show_protein_lean_chart: true,
show_heuristics: true,
show_macro_daily_bars: true,
show_macro_distribution_pair: true,
show_energy_protein_charts: true,
}
export const NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_goals_strip: false,
show_intro_blurb: false,
show_kpis: true,
kpi_detail: 'compact',
show_kcal_vs_weight: true,
show_calorie_balance_chart: false,
show_protein_lean_chart: false,
show_heuristics: false,
show_macro_daily_bars: true,
show_macro_distribution_pair: true,
show_energy_protein_charts: false,
}
const BOOL_KEYS = [
'show_goals_strip',
'show_intro_blurb',
'show_kpis',
'show_kcal_vs_weight',
'show_calorie_balance_chart',
'show_protein_lean_chart',
'show_heuristics',
'show_macro_daily_bars',
'show_macro_distribution_pair',
'show_energy_protein_charts',
]
/**
* @param {Record<string, unknown>|null|undefined} raw
*/
export function normalizeNutritionHistoryVizConfig(raw) {
const base = { ...NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
base.kpi_detail = raw.kpi_detail
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}
/**
* @param {Array<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterNutritionHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -0,0 +1,85 @@
/**
* Sichtbarkeit für recovery_history_viz (sync mit backend dashboard_widget_config).
* `visibility === undefined` Verlauf: volle Erholungs-Übersicht (wie bisher).
*/
export const RECOVERY_HISTORY_VIZ_HISTORY_FULL = {
chart_days: 30,
show_layer_meta: true,
show_kpis: true,
kpi_detail: 'full',
show_progress_insights: true,
show_sleep_section_heading: true,
show_chart_recovery_score: true,
show_chart_sleep_quality: true,
show_chart_sleep_debt: true,
show_heart_section_heading: true,
show_heart_context_card: true,
show_chart_hrv_rhr: true,
show_vitals_extra_heading: true,
show_vitals_extra_trends: true,
}
export const RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS = {
chart_days: 30,
show_layer_meta: false,
show_kpis: true,
kpi_detail: 'compact',
show_progress_insights: false,
show_sleep_section_heading: true,
show_chart_recovery_score: true,
show_chart_sleep_quality: true,
show_chart_sleep_debt: false,
show_heart_section_heading: true,
show_heart_context_card: false,
show_chart_hrv_rhr: true,
show_vitals_extra_heading: false,
show_vitals_extra_trends: false,
}
const BOOL_KEYS = [
'show_layer_meta',
'show_kpis',
'show_progress_insights',
'show_sleep_section_heading',
'show_chart_recovery_score',
'show_chart_sleep_quality',
'show_chart_sleep_debt',
'show_heart_section_heading',
'show_heart_context_card',
'show_chart_hrv_rhr',
'show_vitals_extra_heading',
'show_vitals_extra_trends',
]
/**
* @param {Record<string, unknown>|null|undefined} raw
*/
export function normalizeRecoveryHistoryVizConfig(raw) {
const base = { ...RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS }
if (!raw || typeof raw !== 'object') return base
for (const k of BOOL_KEYS) {
if (Object.prototype.hasOwnProperty.call(raw, k)) {
base[k] = raw[k] === true
}
}
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
base.kpi_detail = raw.kpi_detail
}
if (raw.chart_days != null) {
const n = Number(raw.chart_days)
if (Number.isFinite(n)) {
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
}
}
return base
}
/**
* @param {Array<object>} kpiTiles
* @param {'compact'|'full'} detail
*/
export function filterRecoveryHistoryKpiTiles(kpiTiles, detail) {
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
return kpiTiles.slice(0, 4)
}

View File

@ -14,6 +14,16 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget' import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget' import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget' import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget'
import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget'
import HistoryOverviewVizWidget from '../components/dashboard-widgets/HistoryOverviewVizWidget'
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
import { normalizeHistoryOverviewVizConfig } from './historyOverviewVizConfig'
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget' import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget' import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget' import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
@ -57,6 +67,14 @@ export function ensurePilotLabWidgetsRegistered() {
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days), chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
}), }),
}) })
registerDashboardWidget({
id: 'body_history_viz',
Component: BodyHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
bodyHistoryVizConfig: normalizeBodyHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({ registerDashboardWidget({
id: 'activity_overview', id: 'activity_overview',
Component: PilotActivitySection, Component: PilotActivitySection,
@ -112,6 +130,38 @@ export function ensurePilotLabWidgetsRegistered() {
chartDays: ctx.layoutEntry?.config?.chart_days, chartDays: ctx.layoutEntry?.config?.chart_days,
}), }),
}) })
registerDashboardWidget({
id: 'nutrition_history_viz',
Component: NutritionHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({
id: 'fitness_history_viz',
Component: FitnessHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({
id: 'recovery_history_viz',
Component: RecoveryHistoryVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({
id: 'history_overview_viz',
Component: HistoryOverviewVizWidget,
mapProps: (ctx) => ({
refreshTick: ctx.refreshTick,
historyOverviewVizConfig: normalizeHistoryOverviewVizConfig(ctx.layoutEntry?.config),
}),
})
registerDashboardWidget({ registerDashboardWidget({
id: 'recovery_charts_panel', id: 'recovery_charts_panel',
Component: RecoveryChartsPanelWidget, Component: RecoveryChartsPanelWidget,

BIN
shinkan-dev-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

40
test-shinkan.js Normal file
View File

@ -0,0 +1,40 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
console.log('=== Testing Shinkan Frontend ===\n');
try {
await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 });
const title = await page.title();
console.log('📄 Title:', title);
const h1 = await page.textContent('h1').catch(() => null);
console.log('🥋 Heading:', h1);
const bodyText = await page.evaluate(() => document.body.innerText);
console.log('\n📝 Page Content:\n' + '='.repeat(60));
console.log(bodyText);
console.log('='.repeat(60));
const buttons = await page.locator('button').count();
const forms = await page.locator('form').count();
const inputs = await page.locator('input').count();
console.log('\n🔍 Elements Found:');
console.log(' - Buttons:', buttons);
console.log(' - Forms:', forms);
console.log(' - Inputs:', inputs);
await page.screenshot({ path: 'shinkan-dev-screenshot.png', fullPage: true });
console.log('\n📸 Screenshot: shinkan-dev-screenshot.png');
} catch (error) {
console.error('❌ Error:', error.message);
}
await browser.close();
})();