feat: add history_overview_viz widget and enhance configuration handling
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced the `history_overview_viz` widget to the dashboard, allowing users to visualize consolidated history data across various metrics.
- Updated widget configuration to include `history_overview_viz` in the allowed widgets and added validation for its configuration.
- Enhanced the widget catalog with details for the new `history_overview_viz` entry.
- Implemented default values and validation logic for the widget's configuration, ensuring proper handling of user inputs.
- Added tests to ensure proper validation of the `history_overview_viz` widget configuration.
- Bumped application version to reflect the addition of the new widget.
This commit is contained in:
Lars 2026-04-22 11:55:11 +02:00
parent e20b321b64
commit 97dbb0f80b
15 changed files with 1105 additions and 727 deletions

View File

@ -18,6 +18,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"nutrition_history_viz",
"fitness_history_viz",
"recovery_history_viz",
"history_overview_viz",
"activity_overview",
"kpi_board",
"quick_capture",
@ -144,6 +145,23 @@ _RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
"show_vitals_extra_trends": False,
}
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
"show_confidence_banner",
"show_intro_blurb",
"show_area_summaries",
"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_area_summaries": 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"))
@ -171,6 +189,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_fitness_history_viz_config({})
if widget_id == "recovery_history_viz":
return _validate_recovery_history_viz_config({})
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config({})
return {}
if widget_id == "body_overview":
@ -183,6 +203,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
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":
@ -435,6 +457,40 @@ def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]
return out
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
label = "history_overview_viz"
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
unknown = set(raw) - 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:
continue
v = raw[k]
if not isinstance(v, bool):
raise ValueError(f"{label}: {k} muss boolean sein")
out[k] = v
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 any(
out[k]
for k in (
"show_confidence_banner",
"show_area_summaries",
"show_correlation_c1_c3",
"show_drivers_c4",
)
):
raise ValueError(
f"{label}: mindestens Datenlage-Banner, Bereichs-Kacheln, 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

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

View File

@ -9,6 +9,12 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional
from 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
@ -181,6 +187,12 @@ 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

@ -69,10 +69,11 @@ from data_layer.recovery_metrics import (
calculate_rhr_vs_baseline_pct,
calculate_sleep_debt_hours
)
from data_layer.correlations import (
calculate_lag_correlation,
calculate_correlation_sleep_recovery,
calculate_top_drivers
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.utils import serialize_dates, safe_float, calculate_confidence
from data_layer.nutrition_chart_payloads import (
@ -362,7 +363,8 @@ 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).
Layer 2b: Gesamtansicht «Verlauf» KPI-Kurzformen aus den vier History-Bundles,
Lag-Korrelationen C1C4 (Metadaten) und Chart.js-Payloads C1C4 (chart_payloads, wie /charts/*).
"""
profile_id = session["profile_id"]
bundle = get_history_overview_viz_bundle(profile_id, days)
@ -1111,58 +1113,7 @@ def get_weight_energy_correlation_chart(
Chart.js scatter chart with correlation data
"""
profile_id = session['profile_id']
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",
}
}
return build_weight_energy_correlation_chart_payload(profile_id, max_lag)
@router.get("/lbm-protein-correlation")
@ -1183,55 +1134,7 @@ def get_lbm_protein_correlation_chart(
Chart.js scatter chart with correlation data
"""
profile_id = session['profile_id']
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",
}
}
return build_lbm_protein_correlation_chart_payload(profile_id, max_lag)
@router.get("/load-vitals-correlation")
@ -1252,83 +1155,7 @@ def get_load_vitals_correlation_chart(
Chart.js scatter chart with correlation data
"""
profile_id = session['profile_id']
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",
}
}
return build_load_vitals_correlation_chart_payload(profile_id, max_lag)
@router.get("/recovery-performance")
@ -1347,81 +1174,7 @@ def get_recovery_performance_chart(
Chart.js bar chart with top drivers
"""
profile_id = session['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)
}
}
return build_recovery_performance_chart_payload(profile_id)
# ── Health Endpoint ──────────────────────────────────────────────────────────

View File

@ -153,6 +153,41 @@ def test_recovery_history_viz_unknown_key():
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_area_summaries"] 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_area_summaries": False,
"show_correlation_c1_c3": False,
"show_drivers_c4": False,
},
)
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

@ -30,7 +30,7 @@ MODULE_VERSIONS = {
"importdata": "1.0.0",
"membership": "2.1.0",
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
"app_dashboard": "1.16.0", # recovery_history_viz: Verlauf-Bundle-Widget + Config
"app_dashboard": "1.17.0", # history_overview_viz Widget + chart_payloads im Overview-Bundle
"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

@ -117,6 +117,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"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: konsolidierte Kurzinfos (Körper/Ernährung/Fitness/Erholung) + C1C4; chart_payloads im Bundle; chart_days 790; Blöcke per show_*",
},
{
"id": "recovery_charts_panel",
"title": "Erholung — Charts R1R5",

View File

@ -0,0 +1,35 @@
import { useNavigate } from 'react-router-dom'
import HistoryOverviewVizSection from '../history/HistoryOverviewVizSection'
import { normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig'
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
/**
* Verlauf Gesamtübersicht als Dashboard-Widget: GET /charts/history-overview-viz (inkl. chart_payloads C1C4).
* @param {{ refreshTick?: number, historyOverviewVizConfig?: Record<string, unknown> }} props
*/
export default function HistoryOverviewVizWidget({ refreshTick = 0, historyOverviewVizConfig }) {
const nav = useNavigate()
const cfg = normalizeHistoryOverviewVizConfig(historyOverviewVizConfig)
const days = normalizeBodyChartDays(cfg.chart_days)
return (
<div className="card section-gap" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Gesamtübersicht (Verlauf)</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}>history-overview-viz · {days} Tage</div>
</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'overview' } })}>
Verlauf
</button>
</div>
<HistoryOverviewVizSection
key={`${refreshTick}-${days}`}
externalPeriod={days}
hidePeriodSelector
embedded
visibility={cfg}
/>
</div>
)
}

View File

@ -0,0 +1,521 @@
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
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>
)}
{vis.show_area_summaries && (sections.length === 0 ? (
<EmptySection text="Keine Bereichsdaten." />
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
{sections.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

@ -15,6 +15,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
import {
moveWidget,
moveWidgetToIndex,
@ -30,6 +31,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel',
])
@ -571,6 +573,23 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
}
/>
)}
{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

@ -16,6 +16,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit
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 */
@ -27,6 +28,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
'nutrition_history_viz',
'fitness_history_viz',
'recovery_history_viz',
'history_overview_viz',
'recovery_charts_panel',
])
@ -336,6 +338,8 @@ export default function DashboardLabPage() {
? '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'}{' '}
@ -360,6 +364,8 @@ export default function DashboardLabPage() {
? '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'
@ -466,6 +472,23 @@ export default function DashboardLabPage() {
}
/>
)}
{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

@ -1,12 +1,6 @@
import { useState, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useLocation } from 'react-router-dom'
import { useProfile } from '../context/ProfileContext'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
ReferenceLine, Cell, ComposedChart,
ScatterChart, Scatter,
} from 'recharts'
import { Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
import { api } from '../utils/api'
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
@ -16,7 +10,8 @@ import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSec
import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection'
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection'
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome'
import HistoryOverviewVizSection from '../components/history/HistoryOverviewVizSection'
import { EmptySection, PeriodSelector, SectionHeader } from '../components/history/historyPageChrome'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -138,459 +133,6 @@ function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, f
)
}
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) }))
}
/** Backend metadata.lag_details: [{ lag, n_pairs, r }] — für Lag-Kurve L → r (C3: ggf. lag_details_hrv / lag_details_rhr) */
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>
)
}
// Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1C4)
function HistoryOverviewSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
const navigate = useNavigate()
const [period, setPeriod] = useState(30)
const [bundle, setBundle] = useState(null)
const [err, setErr] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const daysReq = period === 9999 ? 3650 : period
setLoading(true)
Promise.all([
api.getHistoryOverviewViz(daysReq),
api.getWeightEnergyCorrelationChart(14),
api.getLbmProteinCorrelationChart(14),
api.getLoadVitalsCorrelationChart(14),
api.getRecoveryPerformanceChart(),
])
.then(([overview, chartC1, chartC2, chartC3, chartC4]) => {
if (!cancelled) {
setBundle({ overview, chartC1, chartC2, chartC3, chartC4 })
setErr(null)
}
})
.catch((e) => {
if (!cancelled) setErr(e.message || 'Laden fehlgeschlagen')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [period])
if (loading) {
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (err) {
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<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)
return (
<div>
<SectionHeader title="📊 Gesamtansicht" />
<PeriodSelector value={period} onChange={setPeriod} />
<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>
<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 unten nutzen dieselben Chart-Endpunkte wie die API (<code style={{ fontSize: 10 }}>/api/charts/*</code>).
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
{sections.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>
<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>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Einflussfaktoren (C4)</div>
<DriversImpactTile payload={chartC4} driversFallback={c4drivers} />
<InsightBox insights={insights} slugs={filterActiveSlugs(['gesamt', 'ziele'])} onRequest={onRequest} loading={loadingSlug} />
</div>
)
}
// Photo Grid
function PhotoGrid() {
const [photos, setPhotos] = useState([])
@ -800,7 +342,18 @@ export default function History() {
</div>
</nav>
<div className="history-content">
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
{tab === 'overview' && (
<HistoryOverviewVizSection
footer={(
<InsightBox
insights={insights}
slugs={filterActiveSlugs(['gesamt', 'ziele'])}
onRequest={requestInsight}
loading={loadingSlug}
/>
)}
/>
)}
{tab==='body' && (
<BodyHistoryVizSection
profile={profile}

View File

@ -0,0 +1,50 @@
import {
HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS,
normalizeHistoryOverviewVizConfig,
} from './historyOverviewVizConfig'
const TOGGLES = [
{ key: 'show_confidence_banner', label: 'Banner «Datenlage»' },
{ key: 'show_intro_blurb', label: 'Hinweistext (Ernährung / API)' },
{ key: 'show_area_summaries', label: 'Kacheln Körper · Ernährung · Fitness · Erholung' },
{ 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> konsolidierte Kurzinfos und Korrelations-Kacheln wie im Verlauf-Reiter «Gesamt».
</div>
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{TOGGLES.map(({ key, label }) => (
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
<span>{label}</span>
</label>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,50 @@
/**
* 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_PAGE_FULL = {
chart_days: 30,
show_confidence_banner: true,
show_intro_blurb: true,
show_area_summaries: 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_area_summaries: true,
show_correlation_c1_c3: true,
show_drivers_c4: true,
}
const BOOL_KEYS = [
'show_confidence_banner',
'show_intro_blurb',
'show_area_summaries',
'show_correlation_c1_c3',
'show_drivers_c4',
]
/**
* @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
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

@ -18,10 +18,12 @@ import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryViz
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'
@ -152,6 +154,14 @@ export function ensurePilotLabWidgetsRegistered() {
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,