feat: add history_overview_viz widget and enhance configuration handling
- 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:
parent
e20b321b64
commit
97dbb0f80b
|
|
@ -18,6 +18,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||||
"nutrition_history_viz",
|
"nutrition_history_viz",
|
||||||
"fitness_history_viz",
|
"fitness_history_viz",
|
||||||
"recovery_history_viz",
|
"recovery_history_viz",
|
||||||
|
"history_overview_viz",
|
||||||
"activity_overview",
|
"activity_overview",
|
||||||
"kpi_board",
|
"kpi_board",
|
||||||
"quick_capture",
|
"quick_capture",
|
||||||
|
|
@ -144,6 +145,23 @@ _RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||||
"show_vitals_extra_trends": False,
|
"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:
|
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||||
|
|
@ -171,6 +189,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||||
return _validate_fitness_history_viz_config({})
|
return _validate_fitness_history_viz_config({})
|
||||||
if widget_id == "recovery_history_viz":
|
if widget_id == "recovery_history_viz":
|
||||||
return _validate_recovery_history_viz_config({})
|
return _validate_recovery_history_viz_config({})
|
||||||
|
if widget_id == "history_overview_viz":
|
||||||
|
return _validate_history_overview_viz_config({})
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if widget_id == "body_overview":
|
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)
|
return _validate_fitness_history_viz_config(raw)
|
||||||
if widget_id == "recovery_history_viz":
|
if widget_id == "recovery_history_viz":
|
||||||
return _validate_recovery_history_viz_config(raw)
|
return _validate_recovery_history_viz_config(raw)
|
||||||
|
if widget_id == "history_overview_viz":
|
||||||
|
return _validate_history_overview_viz_config(raw)
|
||||||
if widget_id == "activity_overview":
|
if widget_id == "activity_overview":
|
||||||
return _validate_chart_days_only(raw, label="activity_overview")
|
return _validate_chart_days_only(raw, label="activity_overview")
|
||||||
if widget_id == "kpi_board":
|
if widget_id == "kpi_board":
|
||||||
|
|
@ -435,6 +457,40 @@ def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]
|
||||||
return out
|
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]:
|
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||||
allowed = frozenset({"chart_days"})
|
allowed = frozenset({"chart_days"})
|
||||||
unknown = set(raw) - allowed
|
unknown = set(raw) - allowed
|
||||||
|
|
|
||||||
256
backend/data_layer/correlation_chart_payloads.py
Normal file
256
backend/data_layer/correlation_chart_payloads.py
Normal file
|
|
@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,12 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
from data_layer.body_viz import get_body_history_viz_bundle
|
||||||
|
from data_layer.correlation_chart_payloads import (
|
||||||
|
build_lbm_protein_correlation_chart_payload,
|
||||||
|
build_load_vitals_correlation_chart_payload,
|
||||||
|
build_recovery_performance_chart_payload,
|
||||||
|
build_weight_energy_correlation_chart_payload,
|
||||||
|
)
|
||||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
||||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||||
|
|
@ -181,6 +187,12 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any
|
||||||
"drivers": drv_list[:8],
|
"drivers": drv_list[:8],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"chart_payloads": {
|
||||||
|
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
|
||||||
|
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
|
||||||
|
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
|
||||||
|
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
|
||||||
|
},
|
||||||
"meta": {
|
"meta": {
|
||||||
"layer_1": "composed_metrics",
|
"layer_1": "composed_metrics",
|
||||||
"layer_2b": "history_overview_viz",
|
"layer_2b": "history_overview_viz",
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,11 @@ from data_layer.recovery_metrics import (
|
||||||
calculate_rhr_vs_baseline_pct,
|
calculate_rhr_vs_baseline_pct,
|
||||||
calculate_sleep_debt_hours
|
calculate_sleep_debt_hours
|
||||||
)
|
)
|
||||||
from data_layer.correlations import (
|
from data_layer.correlation_chart_payloads import (
|
||||||
calculate_lag_correlation,
|
build_lbm_protein_correlation_chart_payload,
|
||||||
calculate_correlation_sleep_recovery,
|
build_load_vitals_correlation_chart_payload,
|
||||||
calculate_top_drivers
|
build_recovery_performance_chart_payload,
|
||||||
|
build_weight_energy_correlation_chart_payload,
|
||||||
)
|
)
|
||||||
from data_layer.utils import serialize_dates, safe_float, calculate_confidence
|
from data_layer.utils import serialize_dates, safe_float, calculate_confidence
|
||||||
from data_layer.nutrition_chart_payloads import (
|
from data_layer.nutrition_chart_payloads import (
|
||||||
|
|
@ -362,7 +363,8 @@ def get_history_overview_viz(
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen 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"]
|
profile_id = session["profile_id"]
|
||||||
bundle = get_history_overview_viz_bundle(profile_id, days)
|
bundle = get_history_overview_viz_bundle(profile_id, days)
|
||||||
|
|
@ -1111,58 +1113,7 @@ def get_weight_energy_correlation_chart(
|
||||||
Chart.js scatter chart with correlation data
|
Chart.js scatter chart with correlation data
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_weight_energy_correlation_chart_payload(profile_id, max_lag)
|
||||||
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get('correlation') is None:
|
|
||||||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53)
|
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
|
||||||
correlation = corr_data.get('correlation', 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#1D9E75",
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
|
||||||
"data_points": corr_data.get('data_points', 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
|
||||||
"layer_1": "correlations._correlate_energy_weight",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/lbm-protein-correlation")
|
@router.get("/lbm-protein-correlation")
|
||||||
|
|
@ -1183,55 +1134,7 @@ def get_lbm_protein_correlation_chart(
|
||||||
Chart.js scatter chart with correlation data
|
Chart.js scatter chart with correlation data
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_lbm_protein_correlation_chart_payload(profile_id, max_lag)
|
||||||
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get('correlation') is None:
|
|
||||||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
|
||||||
correlation = corr_data.get('correlation', 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Lag {best_lag} Tage"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#3B82F6",
|
|
||||||
"borderColor": "#1E40AF",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
|
||||||
"data_points": corr_data.get('data_points', 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_protein_lbm",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/load-vitals-correlation")
|
@router.get("/load-vitals-correlation")
|
||||||
|
|
@ -1252,83 +1155,7 @@ def get_load_vitals_correlation_chart(
|
||||||
Chart.js scatter chart with correlation data
|
Chart.js scatter chart with correlation data
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_load_vitals_correlation_chart_payload(profile_id, max_lag)
|
||||||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
|
||||||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
|
||||||
|
|
||||||
def _abs_corr(c):
|
|
||||||
if not c or c.get("correlation") is None:
|
|
||||||
return -1.0
|
|
||||||
try:
|
|
||||||
return abs(float(c["correlation"]))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return -1.0
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
|
||||||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
|
||||||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
|
||||||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
|
||||||
if h_msg or r_msg:
|
|
||||||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
|
||||||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
|
||||||
corr_data = corr_hrv
|
|
||||||
metric_name = "HRV"
|
|
||||||
else:
|
|
||||||
corr_data = corr_rhr
|
|
||||||
metric_name = "RHR"
|
|
||||||
|
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
|
||||||
correlation = corr_data.get('correlation', 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {
|
|
||||||
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Korrelation",
|
|
||||||
"data": [{"x": best_lag, "y": correlation}],
|
|
||||||
"backgroundColor": "#F59E0B",
|
|
||||||
"borderColor": "#D97706",
|
|
||||||
"borderWidth": 2,
|
|
||||||
"pointRadius": 8
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
|
||||||
"correlation": round(float(correlation), 3),
|
|
||||||
"best_lag_days": best_lag,
|
|
||||||
"metric": metric_name,
|
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
|
||||||
"data_points": corr_data.get('data_points', 0),
|
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_load_vitals",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/recovery-performance")
|
@router.get("/recovery-performance")
|
||||||
|
|
@ -1347,81 +1174,7 @@ def get_recovery_performance_chart(
|
||||||
Chart.js bar chart with top drivers
|
Chart.js bar chart with top drivers
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return build_recovery_performance_chart_payload(profile_id)
|
||||||
# Get top drivers (hindering/helpful factors)
|
|
||||||
drivers = calculate_top_drivers(profile_id)
|
|
||||||
|
|
||||||
if not drivers or len(drivers) == 0:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Nicht genug Daten für Driver-Analyse"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Separate hindering and helpful
|
|
||||||
hindering = [d for d in drivers if d.get('impact', '') == 'hindering']
|
|
||||||
helpful = [d for d in drivers if d.get('impact', '') == 'helpful']
|
|
||||||
|
|
||||||
# Take top 3 of each
|
|
||||||
top_hindering = hindering[:3]
|
|
||||||
top_helpful = helpful[:3]
|
|
||||||
|
|
||||||
labels = []
|
|
||||||
values = []
|
|
||||||
colors = []
|
|
||||||
|
|
||||||
for d in top_hindering:
|
|
||||||
labels.append(f"❌ {d.get('factor', '')}")
|
|
||||||
values.append(-abs(d.get('score', 0))) # Negative for hindering
|
|
||||||
colors.append("#EF4444")
|
|
||||||
|
|
||||||
for d in top_helpful:
|
|
||||||
labels.append(f"✅ {d.get('factor', '')}")
|
|
||||||
values.append(abs(d.get('score', 0))) # Positive for helpful
|
|
||||||
colors.append("#1D9E75")
|
|
||||||
|
|
||||||
if not labels:
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": [],
|
|
||||||
"datasets": []
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "low",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": "Keine signifikanten Treiber gefunden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"chart_type": "bar",
|
|
||||||
"data": {
|
|
||||||
"labels": labels,
|
|
||||||
"datasets": [
|
|
||||||
{
|
|
||||||
"label": "Impact Score",
|
|
||||||
"data": values,
|
|
||||||
"backgroundColor": colors,
|
|
||||||
"borderColor": "#085041",
|
|
||||||
"borderWidth": 1
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "medium",
|
|
||||||
"hindering_count": len(top_hindering),
|
|
||||||
"helpful_count": len(top_helpful),
|
|
||||||
"total_factors": len(drivers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Health Endpoint ──────────────────────────────────────────────────────────
|
# ── Health Endpoint ──────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,41 @@ def test_recovery_history_viz_unknown_key():
|
||||||
validate_widget_entry_config("recovery_history_viz", {"evil": True})
|
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():
|
def test_welcome_config_rejected_unknown_key():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
validate_widget_entry_config("welcome", {"x": 1})
|
validate_widget_entry_config("welcome", {"x": 1})
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
||||||
"importdata": "1.0.0",
|
"importdata": "1.0.0",
|
||||||
"membership": "2.1.0",
|
"membership": "2.1.0",
|
||||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||||
"app_dashboard": "1.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
|
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
||||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,11 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
||||||
"title": "Erholung (Verlauf-Bundle)",
|
"title": "Erholung (Verlauf-Bundle)",
|
||||||
"description": "Layer-2b recovery-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90",
|
"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",
|
"id": "recovery_charts_panel",
|
||||||
"title": "Erholung — Charts R1–R5",
|
"title": "Erholung — Charts R1–R5",
|
||||||
|
|
|
||||||
|
|
@ -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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
521
frontend/src/components/history/HistoryOverviewVizSection.jsx
Normal file
521
frontend/src/components/history/HistoryOverviewVizSection.jsx
Normal 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 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<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 C1–C4 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 (C1–C3)</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit
|
||||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||||
|
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||||
import {
|
import {
|
||||||
moveWidget,
|
moveWidget,
|
||||||
moveWidgetToIndex,
|
moveWidgetToIndex,
|
||||||
|
|
@ -30,6 +31,7 @@ const CHART_DAYS_WIDGET_IDS = new Set([
|
||||||
'nutrition_history_viz',
|
'nutrition_history_viz',
|
||||||
'fitness_history_viz',
|
'fitness_history_viz',
|
||||||
'recovery_history_viz',
|
'recovery_history_viz',
|
||||||
|
'history_overview_viz',
|
||||||
'recovery_charts_panel',
|
'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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEdit
|
||||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||||
|
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||||
|
|
||||||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
/** 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',
|
'nutrition_history_viz',
|
||||||
'fitness_history_viz',
|
'fitness_history_viz',
|
||||||
'recovery_history_viz',
|
'recovery_history_viz',
|
||||||
|
'history_overview_viz',
|
||||||
'recovery_charts_panel',
|
'recovery_charts_panel',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
@ -336,6 +338,8 @@ export default function DashboardLabPage() {
|
||||||
? 'Ernährung (Verlauf-Bundle)'
|
? 'Ernährung (Verlauf-Bundle)'
|
||||||
: w.id === 'fitness_history_viz'
|
: w.id === 'fitness_history_viz'
|
||||||
? 'Fitness (Verlauf-Bundle)'
|
? 'Fitness (Verlauf-Bundle)'
|
||||||
|
: w.id === 'history_overview_viz'
|
||||||
|
? 'Gesamtübersicht (Verlauf-Bundle)'
|
||||||
: w.id === 'recovery_history_viz'
|
: w.id === 'recovery_history_viz'
|
||||||
? 'Erholung (Verlauf-Bundle)'
|
? 'Erholung (Verlauf-Bundle)'
|
||||||
: 'Erholung — Charts'}{' '}
|
: 'Erholung — Charts'}{' '}
|
||||||
|
|
@ -360,6 +364,8 @@ export default function DashboardLabPage() {
|
||||||
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
||||||
: w.id === 'fitness_history_viz'
|
: w.id === 'fitness_history_viz'
|
||||||
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
|
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
|
||||||
|
: w.id === 'history_overview_viz'
|
||||||
|
? 'Gesamtübersicht Verlauf-Bundle Zeitraum in Tagen'
|
||||||
: w.id === 'recovery_history_viz'
|
: w.id === 'recovery_history_viz'
|
||||||
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
|
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
|
||||||
: 'Erholungs-Charts 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>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import { useProfile } from '../context/ProfileContext'
|
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 { Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||||
|
|
@ -16,7 +10,8 @@ import FitnessHistoryVizSection from '../components/history/FitnessHistoryVizSec
|
||||||
import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection'
|
import RecoveryHistoryVizSection from '../components/history/RecoveryHistoryVizSection'
|
||||||
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
|
import BodyHistoryVizSection from '../components/history/BodyHistoryVizSection'
|
||||||
import NutritionHistoryVizSection from '../components/history/NutritionHistoryVizSection'
|
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 from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
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 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 (
|
|
||||||
<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 C1–C4 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 (C1–C3)</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 ────────────────────────────────────────────────────────────────
|
// ── Photo Grid ────────────────────────────────────────────────────────────────
|
||||||
function PhotoGrid() {
|
function PhotoGrid() {
|
||||||
const [photos, setPhotos] = useState([])
|
const [photos, setPhotos] = useState([])
|
||||||
|
|
@ -800,7 +342,18 @@ export default function History() {
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="history-content">
|
<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' && (
|
{tab==='body' && (
|
||||||
<BodyHistoryVizSection
|
<BodyHistoryVizSection
|
||||||
profile={profile}
|
profile={profile}
|
||||||
|
|
|
||||||
50
frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx
Normal file
50
frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx
Normal 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 C1–C3 (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>
|
||||||
|
)
|
||||||
|
}
|
||||||
50
frontend/src/widgetSystem/historyOverviewVizConfig.js
Normal file
50
frontend/src/widgetSystem/historyOverviewVizConfig.js
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -18,10 +18,12 @@ import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryViz
|
||||||
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
|
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
|
||||||
import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget'
|
import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget'
|
||||||
import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget'
|
import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget'
|
||||||
|
import HistoryOverviewVizWidget from '../components/dashboard-widgets/HistoryOverviewVizWidget'
|
||||||
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
||||||
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
|
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
|
||||||
import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
|
import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
|
||||||
import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
|
import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
|
||||||
|
import { normalizeHistoryOverviewVizConfig } from './historyOverviewVizConfig'
|
||||||
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||||
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||||
|
|
@ -152,6 +154,14 @@ export function ensurePilotLabWidgetsRegistered() {
|
||||||
recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config),
|
recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
registerDashboardWidget({
|
||||||
|
id: 'history_overview_viz',
|
||||||
|
Component: HistoryOverviewVizWidget,
|
||||||
|
mapProps: (ctx) => ({
|
||||||
|
refreshTick: ctx.refreshTick,
|
||||||
|
historyOverviewVizConfig: normalizeHistoryOverviewVizConfig(ctx.layoutEntry?.config),
|
||||||
|
}),
|
||||||
|
})
|
||||||
registerDashboardWidget({
|
registerDashboardWidget({
|
||||||
id: 'recovery_charts_panel',
|
id: 'recovery_charts_panel',
|
||||||
Component: RecoveryChartsPanelWidget,
|
Component: RecoveryChartsPanelWidget,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user