Compare commits

..

No commits in common. "2dbfd95cca8ee1bb86cd654060737ed5349369e9" and "1cf3d5997d7f13597e86284792b8f149f321257d" have entirely different histories.

46 changed files with 2212 additions and 5890 deletions

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@ Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
"""
from __future__ import annotations
import copy
from typing import Any, Literal
from pydantic import BaseModel, Field, field_validator, model_validator
@ -26,7 +25,6 @@ __all__ = [
"coalesce_effective_layout",
"default_layout_dict",
"lab_default_layout_dict",
"merge_missing_catalog_widgets",
"product_default_layout_dict",
]
@ -54,25 +52,6 @@ def default_layout_dict() -> dict[str, Any]:
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):
id: str = Field(min_length=1, max_length=64)
enabled: bool = True

View File

@ -14,11 +14,6 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"body_overview",
"body_history_viz",
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
"activity_overview",
"kpi_board",
"quick_capture",
@ -37,141 +32,6 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
_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:
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
@ -179,42 +39,19 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
if raw is None:
raw = {}
return {}
if not isinstance(raw, dict):
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
if not raw:
return {}
if widget_id not in WIDGETS_ALLOWING_CONFIG:
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 {}
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
if widget_id == "body_overview":
return _validate_chart_days_only(raw, label="body_overview")
if widget_id == "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":
return _validate_chart_days_only(raw, label="activity_overview")
if widget_id == "kpi_board":
@ -313,210 +150,6 @@ def _parse_chart_days(v: Any, label: str) -> int:
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]:
allowed = frozenset({"chart_days"})
unknown = set(raw) - allowed

View File

@ -1,256 +0,0 @@
"""
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,12 +9,6 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional
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.fitness_viz import get_fitness_dashboard_viz_bundle
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
@ -187,12 +181,6 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any
"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": {
"layer_1": "composed_metrics",
"layer_2b": "history_overview_viz",

View File

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

View File

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

View File

@ -69,11 +69,10 @@ from data_layer.recovery_metrics import (
calculate_rhr_vs_baseline_pct,
calculate_sleep_debt_hours
)
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_correlation_sleep_recovery,
calculate_top_drivers
)
from data_layer.utils import serialize_dates, safe_float, calculate_confidence
from data_layer.nutrition_chart_payloads import (
@ -363,8 +362,7 @@ def get_history_overview_viz(
session: dict = Depends(require_auth),
) -> Dict:
"""
Layer 2b: Gesamtansicht «Verlauf» KPI-Kurzformen aus den vier History-Bundles,
Lag-Korrelationen C1C4 (Metadaten) und Chart.js-Payloads C1C4 (chart_payloads, wie /charts/*).
Layer 2b: Gesamtansicht «Verlauf» KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1C4 (Metadaten).
"""
profile_id = session["profile_id"]
bundle = get_history_overview_viz_bundle(profile_id, days)
@ -1113,7 +1111,58 @@ def get_weight_energy_correlation_chart(
Chart.js scatter chart with correlation data
"""
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")
@ -1134,7 +1183,55 @@ def get_lbm_protein_correlation_chart(
Chart.js scatter chart with correlation data
"""
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")
@ -1155,7 +1252,83 @@ def get_load_vitals_correlation_chart(
Chart.js scatter chart with correlation data
"""
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")
@ -1174,7 +1347,81 @@ def get_recovery_performance_chart(
Chart.js bar chart with top drivers
"""
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 ──────────────────────────────────────────────────────────

View File

@ -5,7 +5,6 @@ from dashboard_layout_schema import (
DashboardLayoutPayload,
coalesce_effective_layout,
default_layout_dict,
merge_missing_catalog_widgets,
)
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
@ -57,19 +56,3 @@ def test_coalesce_valid_raw():
custom, eff = coalesce_effective_layout(raw)
assert custom is True
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,196 +14,6 @@ def test_body_chart_days_bounds():
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():
with pytest.raises(ValueError):
validate_widget_entry_config("welcome", {"x": 1})

View File

@ -32,9 +32,7 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
"version": 1,
"widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)],
}
# 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()
DashboardLayoutPayload.model_validate(small)
class _Cur:
def execute(self, *a, **k):
@ -44,4 +42,4 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
return {"value": small}
monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur())
assert get_product_default_base_dict(object()) == expected
assert get_product_default_base_dict(object()) == small

View File

@ -24,13 +24,13 @@ MODULE_VERSIONS = {
"photos": "1.0.0",
"insights": "1.3.0",
"prompts": "1.1.0",
"admin": "1.4.1", # Produkt-Dashboard-Standard GET/DELETE: merge_missing_catalog_widgets
"admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041)
"stats": "1.0.1",
"exportdata": "1.1.0",
"importdata": "1.0.0",
"membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
"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)
}

View File

@ -42,12 +42,6 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Gewicht & Kennzahlen (optional: config chart_days 790); 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",
"title": "Aktivität",
@ -100,28 +94,6 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Phase-0c NutritionCharts (optional chart_days 790, Default 30); 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",
"title": "Erholung — Charts R1R5",

View File

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

View File

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

View File

@ -17,11 +17,6 @@ import {
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor } from '../utils/interpret'
import {
FITNESS_HISTORY_VIZ_HISTORY_FULL,
filterFitnessHistoryKpiTiles,
normalizeFitnessHistoryVizConfig,
} from '../widgetSystem/fitnessHistoryVizConfig'
import dayjs from 'dayjs'
const PERIODS = [
@ -33,35 +28,21 @@ const PERIODS = [
/**
* 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({
period: periodProp,
onPeriodChange,
hidePeriodSelector = false,
externalPeriod,
embedded = false,
visibility,
footer = null,
}) {
const nav = useNavigate()
const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period =
externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
const setPeriod =
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
const period = controlled ? periodProp : internalPeriod
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true)
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(() => {
let cancelled = false
setLoading(true)
@ -82,13 +63,10 @@ export default function FitnessDashboardOverview({
}
}, [period])
const outerClass = embedded ? '' : 'card section-gap'
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
if (loading) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div className="spinner" style={{ margin: 24 }} />
</div>
)
@ -96,8 +74,8 @@ export default function FitnessDashboardOverview({
if (err) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
@ -105,8 +83,8 @@ export default function FitnessDashboardOverview({
if (!viz?.has_activity_entries) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
<div className="card section-gap">
<div className="card-title">Fitness-Übersicht</div>
<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.
</p>
@ -152,14 +130,11 @@ export default function FitnessDashboardOverview({
}))
const loadMeta = loadCh?.metadata || {}
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...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 eff = viz.effective_window_days
@ -167,59 +142,49 @@ export default function FitnessDashboardOverview({
const dTyp = viz.training_type_dist_days_used
const loadDays = viz.load_chart_days_used
const gridWrapStyle = { width: '100%', minWidth: 0 }
const showPeriodDropdown = !hidePeriodSelector && !controlled
return (
<div className={outerClass || undefined}>
{!embedded && (
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
{showPeriodDropdown ? (
<label
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
{showPeriodDropdown ? (
<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))}
>
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) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
)}
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
{embedded && viz?.last_updated ? (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
Letzte Aktivität {viz.last_updated}
</div>
) : null}
<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>
{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}
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
{kpiTilesShown.length > 0 ? <KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" /> : null}
{display.show_progress_insights && insights.length > 0 ? (
{insights.length > 0 ? (
<div style={{ marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
@ -248,155 +213,136 @@ export default function FitnessDashboardOverview({
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
gap: 16,
marginTop: 8,
minWidth: 0,
}}
>
{display.show_chart_training_volume ? (
<div style={gridWrapStyle}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Trainingsvolumen (Minuten / Woche)
</div>
{volRows.length >= 1 ? (
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={volRows} 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}
interval={0}
angle={-35}
textAnchor="end"
height={48}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
/>
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
)}
<div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Trainingsvolumen (Minuten / Woche)
</div>
) : null}
{volRows.length >= 1 ? (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={volRows} 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}
interval={0}
angle={-35}
textAnchor="end"
height={48}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
formatter={(v) => [`${Math.round(v)} min`, 'Volumen']}
/>
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
</BarChart>
</ResponsiveContainer>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
)}
</div>
{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>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Training nach Kategorie
</div>
) : null}
{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>
{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>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Qualitäts-Sessions (Schätzung)
</div>
) : null}
{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>
{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 style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Belastung (Proxy-Load · duration×RPE / Tag)
</div>
) : null}
{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 style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
)}
</div>
</div>
{footer}
</div>
)
}

View File

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

View File

@ -4,11 +4,6 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
import { api } from '../utils/api'
import KpiTilesOverview from './KpiTilesOverview'
import { getStatusColor, getStatusBg } from '../utils/interpret'
import {
RECOVERY_HISTORY_VIZ_HISTORY_FULL,
filterRecoveryHistoryKpiTiles,
normalizeRecoveryHistoryVizConfig,
} from '../widgetSystem/recoveryHistoryVizConfig'
import dayjs from 'dayjs'
const fmtDate = (d) => dayjs(d).format('DD.MM.')
@ -199,32 +194,17 @@ function ChartCard({ title, loading, error, children, description }) {
/**
* 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({
period: periodProp,
onPeriodChange,
hidePeriodSelector = false,
externalPeriod,
embedded = false,
visibility,
footer = null,
}) {
const nav = useNavigate()
const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period =
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 period = controlled ? periodProp : internalPeriod
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true)
@ -250,13 +230,10 @@ export default function RecoveryDashboardOverview({
}
}, [period])
const outerClass = embedded ? '' : 'card section-gap'
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
if (loading) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="card section-gap">
<div className="card-title">Erholung & Vitalwerte</div>
<div className="spinner" style={{ margin: 24 }} />
</div>
)
@ -264,8 +241,8 @@ export default function RecoveryDashboardOverview({
if (err) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="card section-gap">
<div className="card-title">Erholung & Vitalwerte</div>
<div style={{ color: 'var(--danger)' }}>{err}</div>
</div>
)
@ -273,8 +250,8 @@ export default function RecoveryDashboardOverview({
if (!viz?.has_recovery_data) {
return (
<div className={outerClass || undefined}>
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
<div className="card section-gap">
<div className="card-title">Erholung & Vitalwerte</div>
<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
oder importierst, erscheinen Auswertungen hier.
@ -303,19 +280,18 @@ export default function RecoveryDashboardOverview({
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
...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 eff = viz.effective_window_days
const cDays = viz.chart_days_used
const vDays = viz.vital_matrix_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled
const renderRecoveryScore = () => {
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
return (
@ -330,43 +306,41 @@ export default function RecoveryDashboardOverview({
}))
return (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
domain={[0, 100]}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
tickCount={6}
width={36}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line
type="monotone"
dataKey="score"
stroke="#1D9E75"
strokeWidth={2}
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
dot={{ r: 2 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<YAxis
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
domain={[0, 100]}
tickFormatter={(v) => (Number.isFinite(Number(v)) ? String(Math.round(Number(v))) : '')}
tickCount={6}
width={36}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
borderRadius: 8,
fontSize: 11,
}}
/>
<Line
type="monotone"
dataKey="score"
stroke="#1D9E75"
strokeWidth={2}
name={recoveryData.data?.datasets?.[0]?.label || 'HRV (Proxy)'}
dot={{ r: 2 }}
/>
</LineChart>
</ResponsiveContainer>
<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:{' '}
{recoveryData.metadata.data_points}
@ -390,46 +364,44 @@ export default function RecoveryDashboardOverview({
}))
return (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
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 }} />
</LineChart>
</ResponsiveContainer>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<YAxis
yAxisId="left"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
tickFormatter={formatAxisTick}
tickCount={6}
width={44}
/>
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
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 }} />
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
</div>
@ -452,31 +424,29 @@ export default function RecoveryDashboardOverview({
}))
return (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} 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(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]} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
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 }} />
</LineChart>
</ResponsiveContainer>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} 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(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]} />
<Tooltip
contentStyle={{
background: 'var(--surface)',
border: '1px solid var(--border)',
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 }} />
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
</div>
@ -499,37 +469,35 @@ export default function RecoveryDashboardOverview({
const curDebt = debtData.metadata?.current_debt_hours
return (
<>
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<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="debt"
stroke="#EF4444"
strokeWidth={2}
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
dot={{ r: 2 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} 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(chartData.length / 6) - 1)}
/>
<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="debt"
stroke="#EF4444"
strokeWidth={2}
name={debtData.data?.datasets?.[0]?.label || 'Schlafschuld (h)'}
dot={{ r: 2 }}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
</div>
@ -609,7 +577,7 @@ export default function RecoveryDashboardOverview({
Ein Messpunkt ({formatAxisTick(m.last)}) weiter erfassen, um einen Verlauf zu sehen.
</div>
) : (
<div style={{ width: '100%', minWidth: 0, height: hasMa ? chartHVitals : chartH, minHeight: hasMa ? chartHVitals : chartH }}>
<div style={{ width: '100%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
@ -664,43 +632,37 @@ export default function RecoveryDashboardOverview({
}
return (
<div className={outerClass || undefined}>
{!embedded && (
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Erholung & Vitalwerte</span>
{showPeriodDropdown ? (
<label
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Erholung & Vitalwerte</span>
{showPeriodDropdown ? (
<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))}
>
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>
<option value={28}>28 Tage</option>
<option value={90}>90 Tage</option>
<option value={9999}>Gesamt</option>
</select>
</label>
) : 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>
{display.show_layer_meta ? (
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
Vital-Snapshot <strong>{vDays}</strong> Tage.
</p>
) : null}
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
Vital-Snapshot <strong>{vDays}</strong> Tage.
</p>
{kpiTilesShown.length > 0 ? (
<KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" marginBottom={16} />
) : null}
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
{display.show_progress_insights && insights.length > 0 ? (
{insights.length > 0 ? (
<div style={{ marginBottom: 18 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Überblick: Recovery & Schlaf
@ -728,113 +690,93 @@ export default function RecoveryDashboardOverview({
</div>
) : null}
{display.show_sleep_section_heading ? (
<SectionHeading
compactTop
title="Schlaf & Erholung"
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/>
) : null}
{display.show_chart_recovery_score ? (
<ChartCard
title="HRV-Verlauf (kein Recovery-Score)"
description={
'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' +
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
}
>
{renderRecoveryScore()}
</ChartCard>
) : null}
{display.show_chart_sleep_quality ? (
<ChartCard
title="Schlaf: Dauer & Qualität"
description={
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != 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.`
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
}
>
{renderSleepQuality()}
</ChartCard>
) : null}
{display.show_chart_sleep_debt ? (
<ChartCard
title="Schlafschuld"
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
compactTop
title="Schlaf & Erholung"
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
/>
<ChartCard
title="HRV-Verlauf (kein Recovery-Score)"
description={
'Kurve = HRV-Rohwert (ms), auf 0100 begrenzt — nur zur Einordnung des Verlaufs. ' +
'Die KPI-Kachel «Recovery-Score» oben nutzt calculate_recovery_score_v2 (HRV, RHR, Schlaf, Last, …).'
}
>
{renderRecoveryScore()}
</ChartCard>
<ChartCard
title="Schlaf: Dauer & Qualität"
description={
sleepData && sleepData.metadata?.confidence !== 'insufficient' && sleepData.metadata?.avg_duration_hours != 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.`
: 'Dauer (h) und Qualitätsanteil (%). Sobald genug Daten vorliegen, siehst du die mittlere Schlafdauer unter dem Diagramm.'
}
>
{renderSleepQuality()}
</ChartCard>
<ChartCard
title="Schlafschuld"
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>
{display.show_heart_section_heading ? (
<SectionHeading
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)."
/>
) : null}
{display.show_heart_context_card ? (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
<HeartAutonomicGuide />
{heartSectionInsights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{heartSectionInsights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
<SnapshotCards items={heartSnapshotItems} />
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
{vitalsData?.metadata?.vitals_measured_at ? (
<>
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
</>
) : null}
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
{vitalsData?.metadata?.blood_pressure_measured_at ? (
<>
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
</>
) : null}
</div>
) : null}
{vitalsData?.metadata?.disclaimer_de ? (
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
{vitalsData.metadata.disclaimer_de}
</div>
) : null}
</div>
) : null}
{display.show_chart_hrv_rhr ? (
<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
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>
<HeartAutonomicGuide />
{heartSectionInsights.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 12 }}>
{heartSectionInsights.map((ins) => (
<SectionInsightCard key={ins.key} ins={ins} />
))}
</div>
) : null}
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>Letzte Messwerte (Zonen)</div>
<SnapshotCards items={heartSnapshotItems} />
{vitalsData?.metadata?.vitals_measured_at || vitalsData?.metadata?.blood_pressure_measured_at ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.45 }}>
{vitalsData?.metadata?.vitals_measured_at ? (
<>
Baseline-Vitals: <strong>{fmtDate(vitalsData.metadata.vitals_measured_at)}</strong>
</>
) : null}
{vitalsData?.metadata?.vitals_measured_at && vitalsData?.metadata?.blood_pressure_measured_at ? ' · ' : null}
{vitalsData?.metadata?.blood_pressure_measured_at ? (
<>
Blutdruck: <strong>{fmtDate(vitalsData.metadata.blood_pressure_measured_at)}</strong>
</>
) : null}
</div>
) : null}
{vitalsData?.metadata?.disclaimer_de ? (
<div style={{ fontSize: 10, color: 'var(--text3)', fontStyle: 'italic', marginBottom: 10 }}>
{vitalsData.metadata.disclaimer_de}
</div>
) : null}
</div>
<ChartCard
title="HRV & Ruhepuls — Zeitverlauf"
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
>
{renderHrvRhr()}
</ChartCard>
{display.show_vitals_extra_heading ? (
<SectionHeading
title="Weitere Vitalparameter (Verlauf)"
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
/>
) : null}
{display.show_vitals_extra_trends ? (
<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}
<SectionHeading
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>
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
</div>
</div>
)
}

View File

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

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

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

@ -1,34 +0,0 @@
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
</button>
</div>
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} externalPeriod={days} hidePeriodSelector />
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector />
</div>
)
}

View File

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

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

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

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

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

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

@ -1,87 +0,0 @@
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,11 +11,6 @@ import {
} from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
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,
moveWidgetToIndex,
@ -25,13 +20,8 @@ import {
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
'body_history_viz',
'activity_overview',
'nutrition_detail_charts',
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel',
])
@ -505,91 +495,6 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
/>
</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>
)
})}

View File

@ -12,23 +12,13 @@ import {
} from '../widgetSystem/bodyChartDays'
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
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'
/** Widgets mit optionalem config.chart_days (790), gleiche UX im Editor */
const CHART_DAYS_WIDGET_IDS = new Set([
'body_overview',
'body_history_viz',
'activity_overview',
'nutrition_detail_charts',
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel',
])
@ -328,21 +318,11 @@ export default function DashboardLabPage() {
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
{w.id === 'body_overview'
? 'Körper-Chart'
: w.id === 'body_history_viz'
? 'Körper (Verlauf-Bundle)'
: w.id === 'activity_overview'
? 'Aktivität (Verteilung & Konsistenz)'
: 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'}{' '}
: w.id === 'activity_overview'
? 'Aktivität (Verteilung & Konsistenz)'
: w.id === 'nutrition_detail_charts'
? 'Ernährung — Charts'
: 'Erholung — Charts'}{' '}
Zeitraum (Tage): {BODY_CHART_DAYS_MIN}{BODY_CHART_DAYS_MAX}
</label>
<input
@ -354,21 +334,11 @@ export default function DashboardLabPage() {
aria-label={
w.id === 'body_overview'
? 'Körper-Chart Zeitraum in Tagen'
: w.id === 'body_history_viz'
? 'Körper Verlauf-Bundle Zeitraum in Tagen'
: w.id === 'activity_overview'
? 'Aktivität 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'
: w.id === 'activity_overview'
? 'Aktivität Zeitraum in Tagen'
: w.id === 'nutrition_detail_charts'
? 'Ernährungs-Charts Zeitraum in Tagen'
: 'Erholungs-Charts Zeitraum in Tagen'
}
value={
chartDaysDraftByWidgetId[w.id] !== undefined
@ -404,91 +374,6 @@ export default function DashboardLabPage() {
/>
</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>
)
})}

File diff suppressed because it is too large Load Diff

View File

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

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

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

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

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

@ -1,81 +0,0 @@
/**
* 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

@ -1,70 +0,0 @@
/**
* 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

@ -1,75 +0,0 @@
/**
* 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

@ -1,79 +0,0 @@
/**
* 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

@ -1,85 +0,0 @@
/**
* 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,16 +14,6 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
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 ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
@ -67,14 +57,6 @@ export function ensurePilotLabWidgetsRegistered() {
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({
id: 'activity_overview',
Component: PilotActivitySection,
@ -130,38 +112,6 @@ export function ensurePilotLabWidgetsRegistered() {
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({
id: 'recovery_charts_panel',
Component: RecoveryChartsPanelWidget,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

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