diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py index e6310fc..193453b 100644 --- a/backend/dashboard_widget_config.py +++ b/backend/dashboard_widget_config.py @@ -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 (C1–C3) 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 diff --git a/backend/data_layer/correlation_chart_payloads.py b/backend/data_layer/correlation_chart_payloads.py new file mode 100644 index 0000000..b2ea43f --- /dev/null +++ b/backend/data_layer/correlation_chart_payloads.py @@ -0,0 +1,256 @@ +""" +Chart.js-kompatible Payloads für Lag-Korrelationen C1–C3 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), + }, + } diff --git a/backend/data_layer/history_overview_viz.py b/backend/data_layer/history_overview_viz.py index 4d278c2..c4ad9e6 100644 --- a/backend/data_layer/history_overview_viz.py +++ b/backend/data_layer/history_overview_viz.py @@ -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", diff --git a/backend/routers/charts.py b/backend/routers/charts.py index 220f8c0..fd72d82 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -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 C1–C4 (Metadaten). + Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles, + Lag-Korrelationen C1–C4 (Metadaten) und Chart.js-Payloads C1–C4 (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 ────────────────────────────────────────────────────────── diff --git a/backend/tests/test_dashboard_widget_config.py b/backend/tests/test_dashboard_widget_config.py index 496dcd2..a1e0038 100644 --- a/backend/tests/test_dashboard_widget_config.py +++ b/backend/tests/test_dashboard_widget_config.py @@ -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}) diff --git a/backend/version.py b/backend/version.py index 174510d..58b208d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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) } diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index 4d5947b..5212aa2 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -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 7–90", }, + { + "id": "history_overview_viz", + "title": "Verlauf — Gesamtübersicht", + "description": "Layer-2b history-overview-viz: konsolidierte Kurzinfos (Körper/Ernährung/Fitness/Erholung) + C1–C4; chart_payloads im Bundle; chart_days 7–90; Blöcke per show_*", + }, { "id": "recovery_charts_panel", "title": "Erholung — Charts R1–R5", diff --git a/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx b/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx new file mode 100644 index 0000000..5350390 --- /dev/null +++ b/frontend/src/components/dashboard-widgets/HistoryOverviewVizWidget.jsx @@ -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 C1–C4). + * @param {{ refreshTick?: number, historyOverviewVizConfig?: Record }} props + */ +export default function HistoryOverviewVizWidget({ refreshTick = 0, historyOverviewVizConfig }) { + const nav = useNavigate() + const cfg = normalizeHistoryOverviewVizConfig(historyOverviewVizConfig) + const days = normalizeBodyChartDays(cfg.chart_days) + + return ( +
+
+
+
Gesamtübersicht (Verlauf)
+
history-overview-viz · {days} Tage
+
+ +
+ +
+ ) +} diff --git a/frontend/src/components/history/HistoryOverviewVizSection.jsx b/frontend/src/components/history/HistoryOverviewVizSection.jsx new file mode 100644 index 0000000..bed2ccc --- /dev/null +++ b/frontend/src/components/history/HistoryOverviewVizSection.jsx @@ -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 ( +
+
{title}
+
+ 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}` : ''} +
+ {!hasChart ? ( + <> +
+ {meta.message || 'Keine Daten für diese Korrelation.'} +
+ {curve.length > 0 && ( +
+ Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung. +
+ )} + {curve.length > 0 && ( + + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + + + + )} + + ) : curve.length >= 1 ? ( + <> +
+ Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag. +
+ + + + + + + [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} + /> + { + const { cx, cy, payload: pl } = props + if (cx == null || cy == null || !pl) return null + const isBest = bestLag != null && Number(pl.lag) === bestLag + return ( + + ) + }} + /> + + + + ) : ( + + + + + + + + + + + )} + {meta.interpretation ? ( +
{meta.interpretation}
+ ) : null} +
+ ) +} + +function DriversImpactTile({ payload, driversFallback }) { + const meta = payload?.metadata || {} + const rows = chartJsBarRows(payload, driversFallback) + if (!rows.length) { + return ( +
+
C4 Einflussfaktoren
+
{meta.message || 'Keine Treiber-Daten.'}
+
+ ) + } + const h = Math.min(220, Math.max(96, rows.length * 34)) + return ( +
+
C4 Einflussfaktoren
+ + + + + { + if (!active || !pp?.length) return null + const p = pp[0].payload + return ( +
+
{p.name}
+ {p.subtitle ?
{p.subtitle}
: null} +
+ ) + }} + /> + + {rows.map((e, i) => ( + + ))} + +
+
+
+ ) +} + +/** + * Verlauf «Gesamt» / Dashboard-Widget: Layer-2b history-overview-viz (+ chart_payloads C1–C4). + * + * @param {object} props + * @param {import('react').ReactNode} [props.footer] + * @param {number} [props.externalPeriod] — feste Tage (Widget); sonst interner PeriodSelector (30…9999) + * @param {boolean} [props.hidePeriodSelector] + * @param {boolean} [props.embedded] + * @param {Record} [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 ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } +
+
+ ) + } + if (err) { + return ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } +
{err}
+
+ ) + } + + 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 ( +
+ {!embedded && } + {!hidePeriodSelector && externalPeriod == null && } + + {vis.show_confidence_banner && ( +
+ {confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'} +
+
{confUi.label}
+
{confUi.hint}
+
+
+ )} + + {vis.show_intro_blurb && ( +

+ KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '} + Ehem. «Korrelation»-Charts (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '} + + . Die Kacheln C1–C4 entsprechen denselben Chart.js-Payloads wie /api/charts/* (bei aktuellem Backend im Overview-Bundle enthalten). +

+ )} + + {vis.show_area_summaries && (sections.length === 0 ? ( + + ) : ( +
+ {sections.map((sec) => { + const tone = overviewSectionTone(sec) + const stripe = getStatusColor(tone) + const badgeBg = getStatusBg(tone) + return ( +
+
+
+ + {tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'} + +
{sec.title}
+
+ +
+
{sec.summary_line}
+ + {(sec.kpi_short || []).length > 0 && ( +
+ {(sec.kpi_short || []).map((k, i) => ( +
+
{k.category}
+
{k.value}
+ {k.sublabel ?
{k.sublabel}
: null} +
+ ))} +
+ )} + + {(sec.interpretation_short || []).map((it, i) => ( +
+ {it.title} +
{it.detail}
+
+ ))} + {(sec.heuristic_short || []).map((h, i) => ( +
+ {h.title} +
{h.detail}
+
+ ))} + {(sec.insights_short || []).map((ins, i) => ( +
+ {ins.title} +
{ins.body}
+
+ ))} +
+ ) + })} +
+ ))} + + {vis.show_correlation_c1_c3 && ( + <> +
Lag-Korrelationen (C1–C3)
+
+ + + +
+ + )} + + {vis.show_drivers_c4 && ( + <> +
Einflussfaktoren (C4)
+ + + )} + + {footer} +
+ ) +} diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index 8aabc7a..d8b5c55 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -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' && ( + + 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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 38f41b9..736adc1 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -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 (7–90), 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,9 +338,11 @@ export default function DashboardLabPage() { ? 'Ernährung (Verlauf-Bundle)' : w.id === 'fitness_history_viz' ? 'Fitness (Verlauf-Bundle)' - : w.id === 'recovery_history_viz' - ? 'Erholung (Verlauf-Bundle)' - : 'Erholung — Charts'}{' '} + : w.id === 'history_overview_viz' + ? 'Gesamtübersicht (Verlauf-Bundle)' + : w.id === 'recovery_history_viz' + ? 'Erholung (Verlauf-Bundle)' + : 'Erholung — Charts'}{' '} — Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX} )} + {w.id === 'history_overview_viz' && ( + + 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 } } + }), + }) + ) + } + /> + )} ) })} diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index 8cd9121..d31df96 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -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 ( -
-
{title}
-
- 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}` : ''} -
- {!hasChart ? ( - <> -
- {meta.message || 'Keine Daten für diese Korrelation.'} -
- {curve.length > 0 && ( -
- Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung. -
- )} - {curve.length > 0 && ( - - - - - - - [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} - /> - - - - )} - - ) : curve.length >= 1 ? ( - <> -
- Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag. -
- - - - - - - [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]} - /> - { - const { cx, cy, payload: pl } = props - if (cx == null || cy == null || !pl) return null - const isBest = bestLag != null && Number(pl.lag) === bestLag - return ( - - ) - }} - /> - - - - ) : ( - - - - - - - - - - - )} - {meta.interpretation ? ( -
{meta.interpretation}
- ) : null} -
- ) -} - -function DriversImpactTile({ payload, driversFallback }) { - const meta = payload?.metadata || {} - const rows = chartJsBarRows(payload, driversFallback) - if (!rows.length) { - return ( -
-
C4 Einflussfaktoren
-
{meta.message || 'Keine Treiber-Daten.'}
-
- ) - } - const h = Math.min(220, Math.max(96, rows.length * 34)) - return ( -
-
C4 Einflussfaktoren
- - - - - { - if (!active || !pp?.length) return null - const p = pp[0].payload - return ( -
-
{p.name}
- {p.subtitle ?
{p.subtitle}
: null} -
- ) - }} - /> - - {rows.map((e, i) => ( - - ))} - -
-
-
- ) -} - -// ── Gesamtansicht (Layer 2b: overview + Chart-Endpunkte C1–C4) ────────────────── -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 ( -
- - -
-
- ) - } - if (err) { - return ( -
- -
{err}
-
- ) - } - - 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 ( -
- - - -
- {confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'} -
-
{confUi.label}
-
{confUi.hint}
-
-
- -

- KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '} - Ehem. «Korrelation»-Charts (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '} - - . Die Kacheln C1–C4 unten nutzen dieselben Chart-Endpunkte wie die API (/api/charts/*). -

- -
- {sections.map((sec) => { - const tone = overviewSectionTone(sec) - const stripe = getStatusColor(tone) - const badgeBg = getStatusBg(tone) - return ( -
-
-
- - {tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'} - -
{sec.title}
-
- -
-
{sec.summary_line}
- - {(sec.kpi_short || []).length > 0 && ( -
- {(sec.kpi_short || []).map((k, i) => ( -
-
{k.category}
-
{k.value}
- {k.sublabel ?
{k.sublabel}
: null} -
- ))} -
- )} - - {(sec.interpretation_short || []).map((it, i) => ( -
- {it.title} -
{it.detail}
-
- ))} - {(sec.heuristic_short || []).map((h, i) => ( -
- {h.title} -
{h.detail}
-
- ))} - {(sec.insights_short || []).map((ins, i) => ( -
- {ins.title} -
{ins.body}
-
- ))} -
- ) - })} -
- -
Lag-Korrelationen (C1–C3)
-
- - - -
- -
Einflussfaktoren (C4)
- - - -
- ) -} - // ── Photo Grid ──────────────────────────────────────────────────────────────── function PhotoGrid() { const [photos, setPhotos] = useState([]) @@ -800,7 +342,18 @@ export default function History() {
- {tab==='overview' && } + {tab === 'overview' && ( + + )} + /> + )} {tab==='body' && ( , onChange: (next: Record) => 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 ( +
+
+ Gesamtübersicht (Verlauf-Bundle): konsolidierte Kurzinfos und Korrelations-Kacheln — wie im Verlauf-Reiter «Gesamt». +
+
Bereiche
+
+ {TOGGLES.map(({ key, label }) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/widgetSystem/historyOverviewVizConfig.js b/frontend/src/widgetSystem/historyOverviewVizConfig.js new file mode 100644 index 0000000..77ba609 --- /dev/null +++ b/frontend/src/widgetSystem/historyOverviewVizConfig.js @@ -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|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 +} diff --git a/frontend/src/widgetSystem/registerPilotLabWidgets.js b/frontend/src/widgetSystem/registerPilotLabWidgets.js index 35e478f..7c7260c 100644 --- a/frontend/src/widgetSystem/registerPilotLabWidgets.js +++ b/frontend/src/widgetSystem/registerPilotLabWidgets.js @@ -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,