Merge pull request 'Widget Update' (#102) from develop into main
Reviewed-on: #102
This commit is contained in:
commit
2dbfd95cca
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
1202
.claude/docs/working/SHINKAN_PROJECT_SETUP.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -5,6 +5,7 @@ Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
|
@ -25,6 +26,7 @@ __all__ = [
|
|||
"coalesce_effective_layout",
|
||||
"default_layout_dict",
|
||||
"lab_default_layout_dict",
|
||||
"merge_missing_catalog_widgets",
|
||||
"product_default_layout_dict",
|
||||
]
|
||||
|
||||
|
|
@ -52,6 +54,25 @@ def default_layout_dict() -> dict[str, Any]:
|
|||
return product_default_layout_dict()
|
||||
|
||||
|
||||
def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config).
|
||||
Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in
|
||||
„Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen.
|
||||
"""
|
||||
out = copy.deepcopy(layout)
|
||||
widgets: list[dict[str, Any]] = list(out.get("widgets") or [])
|
||||
seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")}
|
||||
for e in WIDGET_CATALOG:
|
||||
wid = e["id"]
|
||||
if wid not in seen:
|
||||
widgets.append({"id": wid, "enabled": False, "config": {}})
|
||||
seen.add(wid)
|
||||
out["version"] = out.get("version", 1)
|
||||
out["widgets"] = widgets
|
||||
return out
|
||||
|
||||
|
||||
class DashboardWidgetEntry(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
enabled: bool = True
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ MAX_WIDGET_CONFIG_JSON_BYTES = 3072
|
|||
|
||||
WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
|
||||
"body_overview",
|
||||
"body_history_viz",
|
||||
"nutrition_history_viz",
|
||||
"fitness_history_viz",
|
||||
"recovery_history_viz",
|
||||
"history_overview_viz",
|
||||
"activity_overview",
|
||||
"kpi_board",
|
||||
"quick_capture",
|
||||
|
|
@ -32,6 +37,141 @@ _QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
|
|||
_KPI_TILE_FIXED: frozenset[str] = frozenset({"body_fat", "avg_kcal"})
|
||||
_KPI_REF_TILE_RE = re.compile(r"^ref:[a-z0-9_]{1,64}$")
|
||||
|
||||
_BODY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_goals_strip",
|
||||
"show_intro_blurb",
|
||||
"show_layer_meta",
|
||||
"show_kpis",
|
||||
"show_weight_chart",
|
||||
"show_body_fat_chart",
|
||||
"show_proportion_chart",
|
||||
"show_circumference_index_chart",
|
||||
"show_circumference_lines_chart",
|
||||
})
|
||||
|
||||
_BODY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_goals_strip": False,
|
||||
"show_intro_blurb": False,
|
||||
"show_layer_meta": False,
|
||||
"show_kpis": True,
|
||||
"kpi_detail": "compact",
|
||||
"show_weight_chart": True,
|
||||
"show_body_fat_chart": False,
|
||||
"show_proportion_chart": False,
|
||||
"show_circumference_index_chart": False,
|
||||
"show_circumference_lines_chart": False,
|
||||
}
|
||||
|
||||
_NUTRITION_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_goals_strip",
|
||||
"show_intro_blurb",
|
||||
"show_kpis",
|
||||
"show_kcal_vs_weight",
|
||||
"show_calorie_balance_chart",
|
||||
"show_protein_lean_chart",
|
||||
"show_heuristics",
|
||||
"show_macro_daily_bars",
|
||||
"show_macro_distribution_pair",
|
||||
"show_energy_protein_charts",
|
||||
})
|
||||
|
||||
_NUTRITION_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_goals_strip": False,
|
||||
"show_intro_blurb": False,
|
||||
"show_kpis": True,
|
||||
"kpi_detail": "compact",
|
||||
"show_kcal_vs_weight": True,
|
||||
"show_calorie_balance_chart": False,
|
||||
"show_protein_lean_chart": False,
|
||||
"show_heuristics": False,
|
||||
"show_macro_daily_bars": True,
|
||||
"show_macro_distribution_pair": True,
|
||||
"show_energy_protein_charts": False,
|
||||
}
|
||||
|
||||
_FITNESS_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_layer_meta",
|
||||
"show_kpis",
|
||||
"show_progress_insights",
|
||||
"show_chart_training_volume",
|
||||
"show_chart_training_type_distribution",
|
||||
"show_chart_quality_sessions",
|
||||
"show_chart_load_monitoring",
|
||||
})
|
||||
|
||||
_FITNESS_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_layer_meta": False,
|
||||
"show_kpis": True,
|
||||
"kpi_detail": "compact",
|
||||
"show_progress_insights": False,
|
||||
"show_chart_training_volume": True,
|
||||
"show_chart_training_type_distribution": True,
|
||||
"show_chart_quality_sessions": False,
|
||||
"show_chart_load_monitoring": False,
|
||||
}
|
||||
|
||||
_RECOVERY_HISTORY_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_layer_meta",
|
||||
"show_kpis",
|
||||
"show_progress_insights",
|
||||
"show_sleep_section_heading",
|
||||
"show_chart_recovery_score",
|
||||
"show_chart_sleep_quality",
|
||||
"show_chart_sleep_debt",
|
||||
"show_heart_section_heading",
|
||||
"show_heart_context_card",
|
||||
"show_chart_hrv_rhr",
|
||||
"show_vitals_extra_heading",
|
||||
"show_vitals_extra_trends",
|
||||
})
|
||||
|
||||
_RECOVERY_HISTORY_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_layer_meta": False,
|
||||
"show_kpis": True,
|
||||
"kpi_detail": "compact",
|
||||
"show_progress_insights": False,
|
||||
"show_sleep_section_heading": True,
|
||||
"show_chart_recovery_score": True,
|
||||
"show_chart_sleep_quality": True,
|
||||
"show_chart_sleep_debt": False,
|
||||
"show_heart_section_heading": True,
|
||||
"show_heart_context_card": False,
|
||||
"show_chart_hrv_rhr": True,
|
||||
"show_vitals_extra_heading": False,
|
||||
"show_vitals_extra_trends": False,
|
||||
}
|
||||
|
||||
_HISTORY_OVERVIEW_VIZ_SECTION_KEYS: frozenset[str] = frozenset({
|
||||
"show_section_body",
|
||||
"show_section_nutrition",
|
||||
"show_section_fitness",
|
||||
"show_section_recovery",
|
||||
})
|
||||
|
||||
_HISTORY_OVERVIEW_VIZ_BOOL_KEYS: frozenset[str] = frozenset({
|
||||
"show_confidence_banner",
|
||||
"show_intro_blurb",
|
||||
*_HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
|
||||
"show_correlation_c1_c3",
|
||||
"show_drivers_c4",
|
||||
})
|
||||
|
||||
_HISTORY_OVERVIEW_VIZ_DEFAULTS: dict[str, Any] = {
|
||||
"chart_days": 30,
|
||||
"show_confidence_banner": True,
|
||||
"show_intro_blurb": True,
|
||||
"show_section_body": True,
|
||||
"show_section_nutrition": True,
|
||||
"show_section_fitness": True,
|
||||
"show_section_recovery": True,
|
||||
"show_correlation_c1_c3": True,
|
||||
"show_drivers_c4": True,
|
||||
}
|
||||
|
||||
|
||||
def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
||||
return len(json.dumps(config, sort_keys=True, ensure_ascii=False).encode("utf-8"))
|
||||
|
|
@ -39,19 +179,42 @@ def _config_json_size_bytes(config: dict[str, Any]) -> int:
|
|||
|
||||
def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
|
||||
if raw is None:
|
||||
return {}
|
||||
raw = {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Widget {widget_id}: config muss ein Objekt sein")
|
||||
if _config_json_size_bytes(raw) > MAX_WIDGET_CONFIG_JSON_BYTES:
|
||||
raise ValueError(f"Widget {widget_id}: config zu groß (max. {MAX_WIDGET_CONFIG_JSON_BYTES} Byte JSON)")
|
||||
if not raw:
|
||||
return {}
|
||||
|
||||
if widget_id not in WIDGETS_ALLOWING_CONFIG:
|
||||
if raw:
|
||||
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
|
||||
return {}
|
||||
|
||||
if not raw:
|
||||
if widget_id == "body_history_viz":
|
||||
return _validate_body_history_viz_config({})
|
||||
if widget_id == "nutrition_history_viz":
|
||||
return _validate_nutrition_history_viz_config({})
|
||||
if widget_id == "fitness_history_viz":
|
||||
return _validate_fitness_history_viz_config({})
|
||||
if widget_id == "recovery_history_viz":
|
||||
return _validate_recovery_history_viz_config({})
|
||||
if widget_id == "history_overview_viz":
|
||||
return _validate_history_overview_viz_config({})
|
||||
return {}
|
||||
|
||||
if widget_id == "body_overview":
|
||||
return _validate_chart_days_only(raw, label="body_overview")
|
||||
if widget_id == "body_history_viz":
|
||||
return _validate_body_history_viz_config(raw)
|
||||
if widget_id == "nutrition_history_viz":
|
||||
return _validate_nutrition_history_viz_config(raw)
|
||||
if widget_id == "fitness_history_viz":
|
||||
return _validate_fitness_history_viz_config(raw)
|
||||
if widget_id == "recovery_history_viz":
|
||||
return _validate_recovery_history_viz_config(raw)
|
||||
if widget_id == "history_overview_viz":
|
||||
return _validate_history_overview_viz_config(raw)
|
||||
if widget_id == "activity_overview":
|
||||
return _validate_chart_days_only(raw, label="activity_overview")
|
||||
if widget_id == "kpi_board":
|
||||
|
|
@ -150,6 +313,210 @@ def _parse_chart_days(v: Any, label: str) -> int:
|
|||
raise ValueError(f"{label}: chart_days muss ganze Zahl sein")
|
||||
|
||||
|
||||
def _validate_body_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "body_history_viz"
|
||||
allowed = _BODY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_BODY_HISTORY_VIZ_DEFAULTS)
|
||||
for k in _BODY_HISTORY_VIZ_BOOL_KEYS:
|
||||
if k not in raw:
|
||||
continue
|
||||
v = raw[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "kpi_detail" in raw:
|
||||
kd = raw["kpi_detail"]
|
||||
if kd not in ("compact", "full"):
|
||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
||||
out["kpi_detail"] = kd
|
||||
if "chart_days" in raw:
|
||||
v = _parse_chart_days(raw["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
if not out["show_kpis"] and not any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_weight_chart",
|
||||
"show_body_fat_chart",
|
||||
"show_proportion_chart",
|
||||
"show_circumference_index_chart",
|
||||
"show_circumference_lines_chart",
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart muss sichtbar sein")
|
||||
return out
|
||||
|
||||
|
||||
def _validate_nutrition_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "nutrition_history_viz"
|
||||
allowed = _NUTRITION_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_NUTRITION_HISTORY_VIZ_DEFAULTS)
|
||||
for k in _NUTRITION_HISTORY_VIZ_BOOL_KEYS:
|
||||
if k not in raw:
|
||||
continue
|
||||
v = raw[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "kpi_detail" in raw:
|
||||
kd = raw["kpi_detail"]
|
||||
if kd not in ("compact", "full"):
|
||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
||||
out["kpi_detail"] = kd
|
||||
if "chart_days" in raw:
|
||||
v = _parse_chart_days(raw["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
if not out["show_kpis"] and not any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_kcal_vs_weight",
|
||||
"show_calorie_balance_chart",
|
||||
"show_protein_lean_chart",
|
||||
"show_heuristics",
|
||||
"show_macro_daily_bars",
|
||||
"show_macro_distribution_pair",
|
||||
"show_energy_protein_charts",
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{label}: mindestens KPIs oder ein Chart-Bereich muss sichtbar sein")
|
||||
return out
|
||||
|
||||
|
||||
def _validate_fitness_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "fitness_history_viz"
|
||||
allowed = _FITNESS_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_FITNESS_HISTORY_VIZ_DEFAULTS)
|
||||
for k in _FITNESS_HISTORY_VIZ_BOOL_KEYS:
|
||||
if k not in raw:
|
||||
continue
|
||||
v = raw[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "kpi_detail" in raw:
|
||||
kd = raw["kpi_detail"]
|
||||
if kd not in ("compact", "full"):
|
||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
||||
out["kpi_detail"] = kd
|
||||
if "chart_days" in raw:
|
||||
v = _parse_chart_days(raw["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
if not out["show_kpis"] and not out["show_progress_insights"] and not any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_chart_training_volume",
|
||||
"show_chart_training_type_distribution",
|
||||
"show_chart_quality_sessions",
|
||||
"show_chart_load_monitoring",
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{label}: mindestens KPIs, Einschätzungen oder ein Chart muss sichtbar sein")
|
||||
return out
|
||||
|
||||
|
||||
def _validate_recovery_history_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "recovery_history_viz"
|
||||
allowed = _RECOVERY_HISTORY_VIZ_BOOL_KEYS | frozenset({"chart_days", "kpi_detail"})
|
||||
unknown = set(raw) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_RECOVERY_HISTORY_VIZ_DEFAULTS)
|
||||
for k in _RECOVERY_HISTORY_VIZ_BOOL_KEYS:
|
||||
if k not in raw:
|
||||
continue
|
||||
v = raw[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "kpi_detail" in raw:
|
||||
kd = raw["kpi_detail"]
|
||||
if kd not in ("compact", "full"):
|
||||
raise ValueError(f"{label}: kpi_detail muss 'compact' oder 'full' sein")
|
||||
out["kpi_detail"] = kd
|
||||
if "chart_days" in raw:
|
||||
v = _parse_chart_days(raw["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
if not out["show_kpis"] and not out["show_progress_insights"] and not out["show_heart_context_card"] and not out[
|
||||
"show_vitals_extra_trends"
|
||||
] and not any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_chart_recovery_score",
|
||||
"show_chart_sleep_quality",
|
||||
"show_chart_sleep_debt",
|
||||
"show_chart_hrv_rhr",
|
||||
)
|
||||
):
|
||||
raise ValueError(f"{label}: mindestens KPIs, Überblick, Kontextkarte, Extra-Vitals oder ein Chart muss sichtbar sein")
|
||||
return out
|
||||
|
||||
|
||||
def _migrate_history_overview_viz_raw(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Alt: show_area_summaries → vier show_section_* (nur wo keine expliziten Section-Keys gesetzt)."""
|
||||
r = dict(raw)
|
||||
if "show_area_summaries" not in r:
|
||||
return r
|
||||
leg = r.pop("show_area_summaries")
|
||||
if not isinstance(leg, bool):
|
||||
raise ValueError("history_overview_viz: show_area_summaries muss boolean sein (veraltet — nutze show_section_*)")
|
||||
for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS:
|
||||
if k not in r:
|
||||
r[k] = leg
|
||||
return r
|
||||
|
||||
|
||||
def _validate_history_overview_viz_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
label = "history_overview_viz"
|
||||
raw_m = _migrate_history_overview_viz_raw(raw)
|
||||
allowed = _HISTORY_OVERVIEW_VIZ_BOOL_KEYS | frozenset({"chart_days"})
|
||||
unknown = set(raw_m) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
|
||||
out: dict[str, Any] = dict(_HISTORY_OVERVIEW_VIZ_DEFAULTS)
|
||||
for k in _HISTORY_OVERVIEW_VIZ_BOOL_KEYS:
|
||||
if k not in raw_m:
|
||||
continue
|
||||
v = raw_m[k]
|
||||
if not isinstance(v, bool):
|
||||
raise ValueError(f"{label}: {k} muss boolean sein")
|
||||
out[k] = v
|
||||
if "chart_days" in raw_m:
|
||||
v = _parse_chart_days(raw_m["chart_days"], label)
|
||||
if v < 7 or v > 90:
|
||||
raise ValueError(f"{label}: chart_days muss zwischen 7 und 90 liegen")
|
||||
out["chart_days"] = v
|
||||
has_section = any(out[k] for k in _HISTORY_OVERVIEW_VIZ_SECTION_KEYS)
|
||||
has_other = any(
|
||||
out[k]
|
||||
for k in (
|
||||
"show_confidence_banner",
|
||||
"show_correlation_c1_c3",
|
||||
"show_drivers_c4",
|
||||
)
|
||||
)
|
||||
if not has_section and not has_other:
|
||||
raise ValueError(
|
||||
f"{label}: mindestens eine Bereichs-Kachel, das Datenlage-Banner, Lag-Korrelationen (C1–C3) oder Treiber (C4) muss sichtbar sein"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, Any]:
|
||||
allowed = frozenset({"chart_days"})
|
||||
unknown = set(raw) - allowed
|
||||
|
|
|
|||
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 data_layer.body_viz import get_body_history_viz_bundle
|
||||
from data_layer.correlation_chart_payloads import (
|
||||
build_lbm_protein_correlation_chart_payload,
|
||||
build_load_vitals_correlation_chart_payload,
|
||||
build_recovery_performance_chart_payload,
|
||||
build_weight_energy_correlation_chart_payload,
|
||||
)
|
||||
from data_layer.correlations import calculate_lag_correlation, calculate_top_drivers
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
|
|
@ -181,6 +187,12 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any
|
|||
"drivers": drv_list[:8],
|
||||
},
|
||||
},
|
||||
"chart_payloads": {
|
||||
"c1_weight_energy": build_weight_energy_correlation_chart_payload(profile_id, 14),
|
||||
"c2_protein_lbm": build_lbm_protein_correlation_chart_payload(profile_id, 14),
|
||||
"c3_load_vitals": build_load_vitals_correlation_chart_payload(profile_id, 14),
|
||||
"c4_recovery_performance": build_recovery_performance_chart_payload(profile_id),
|
||||
},
|
||||
"meta": {
|
||||
"layer_1": "composed_metrics",
|
||||
"layer_2b": "history_overview_viz",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ from fastapi import APIRouter, HTTPException, Depends
|
|||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_admin, hash_pin
|
||||
from models import AdminProfileUpdate
|
||||
from dashboard_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict
|
||||
from dashboard_layout_schema import (
|
||||
ALLOWED_WIDGET_IDS,
|
||||
DashboardLayoutPayload,
|
||||
merge_missing_catalog_widgets,
|
||||
product_default_layout_dict,
|
||||
)
|
||||
from dashboard_widget_entitlements import widgets_catalog_admin_payload
|
||||
from widget_catalog import WIDGET_CATALOG
|
||||
from widget_feature_requirements_db import (
|
||||
|
|
@ -184,7 +189,7 @@ def admin_get_dashboard_product_default(session: dict = Depends(require_admin)):
|
|||
"""Aktueller Produkt-Dashboard-Standard (DB oder Code)."""
|
||||
_ = session
|
||||
with get_db() as conn:
|
||||
layout = get_product_default_base_dict(conn)
|
||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
||||
from_database = get_stored_product_default_validated(conn) is not None
|
||||
code_ref = product_default_layout_dict()
|
||||
return {
|
||||
|
|
@ -217,7 +222,7 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin
|
|||
_ = session
|
||||
with get_db() as conn:
|
||||
delete_product_default_override(conn)
|
||||
layout = get_product_default_base_dict(conn)
|
||||
layout = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
||||
return {"ok": True, "layout": layout, "from_database": False}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from dashboard_layout_schema import (
|
|||
DashboardLayoutPayload,
|
||||
coalesce_effective_layout,
|
||||
lab_default_layout_dict,
|
||||
merge_missing_catalog_widgets,
|
||||
)
|
||||
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload
|
||||
from db import get_cursor, get_db
|
||||
|
|
@ -51,9 +52,11 @@ def get_dashboard_layout(
|
|||
raw = row["dashboard_layout"] if row else None
|
||||
custom, effective = coalesce_effective_layout(raw)
|
||||
with get_db() as conn:
|
||||
base_product = get_product_default_base_dict(conn)
|
||||
base_product = merge_missing_catalog_widgets(get_product_default_base_dict(conn))
|
||||
if not custom:
|
||||
effective = base_product
|
||||
else:
|
||||
effective = merge_missing_catalog_widgets(effective)
|
||||
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
||||
product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn)
|
||||
lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn)
|
||||
|
|
|
|||
|
|
@ -69,10 +69,11 @@ from data_layer.recovery_metrics import (
|
|||
calculate_rhr_vs_baseline_pct,
|
||||
calculate_sleep_debt_hours
|
||||
)
|
||||
from data_layer.correlations import (
|
||||
calculate_lag_correlation,
|
||||
calculate_correlation_sleep_recovery,
|
||||
calculate_top_drivers
|
||||
from data_layer.correlation_chart_payloads import (
|
||||
build_lbm_protein_correlation_chart_payload,
|
||||
build_load_vitals_correlation_chart_payload,
|
||||
build_recovery_performance_chart_payload,
|
||||
build_weight_energy_correlation_chart_payload,
|
||||
)
|
||||
from data_layer.utils import serialize_dates, safe_float, calculate_confidence
|
||||
from data_layer.nutrition_chart_payloads import (
|
||||
|
|
@ -362,7 +363,8 @@ def get_history_overview_viz(
|
|||
session: dict = Depends(require_auth),
|
||||
) -> Dict:
|
||||
"""
|
||||
Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles + Lag-Korrelationen C1–C4 (Metadaten).
|
||||
Layer 2b: Gesamtansicht «Verlauf» — KPI-Kurzformen aus den vier History-Bundles,
|
||||
Lag-Korrelationen C1–C4 (Metadaten) und Chart.js-Payloads C1–C4 (chart_payloads, wie /charts/*).
|
||||
"""
|
||||
profile_id = session["profile_id"]
|
||||
bundle = get_history_overview_viz_bundle(profile_id, days)
|
||||
|
|
@ -1111,58 +1113,7 @@ def get_weight_energy_correlation_chart(
|
|||
Chart.js scatter chart with correlation data
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
||||
|
||||
if not corr_data or corr_data.get('correlation') is None:
|
||||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
||||
if isinstance(corr_data, dict):
|
||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
||||
"message": msg,
|
||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
||||
}
|
||||
}
|
||||
|
||||
# Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53)
|
||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
||||
correlation = corr_data.get('correlation', 0)
|
||||
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {
|
||||
"labels": [f"Lag {best_lag} Tage"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Korrelation",
|
||||
"data": [{"x": best_lag, "y": correlation}],
|
||||
"backgroundColor": "#1D9E75",
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 2,
|
||||
"pointRadius": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": corr_data.get('confidence', 'low'),
|
||||
"correlation": round(float(correlation), 3),
|
||||
"best_lag_days": best_lag,
|
||||
"interpretation": corr_data.get('interpretation', ''),
|
||||
"data_points": corr_data.get('data_points', 0),
|
||||
"lag_details": corr_data.get("lag_details"),
|
||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
||||
"layer_1": "correlations._correlate_energy_weight",
|
||||
}
|
||||
}
|
||||
return build_weight_energy_correlation_chart_payload(profile_id, max_lag)
|
||||
|
||||
|
||||
@router.get("/lbm-protein-correlation")
|
||||
|
|
@ -1183,55 +1134,7 @@ def get_lbm_protein_correlation_chart(
|
|||
Chart.js scatter chart with correlation data
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
||||
|
||||
if not corr_data or corr_data.get('correlation') is None:
|
||||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
||||
if isinstance(corr_data, dict):
|
||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
||||
"message": msg,
|
||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
||||
}
|
||||
}
|
||||
|
||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
||||
correlation = corr_data.get('correlation', 0)
|
||||
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {
|
||||
"labels": [f"Lag {best_lag} Tage"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Korrelation",
|
||||
"data": [{"x": best_lag, "y": correlation}],
|
||||
"backgroundColor": "#3B82F6",
|
||||
"borderColor": "#1E40AF",
|
||||
"borderWidth": 2,
|
||||
"pointRadius": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": corr_data.get('confidence', 'low'),
|
||||
"correlation": round(float(correlation), 3),
|
||||
"best_lag_days": best_lag,
|
||||
"interpretation": corr_data.get('interpretation', ''),
|
||||
"data_points": corr_data.get('data_points', 0),
|
||||
"lag_details": corr_data.get("lag_details"),
|
||||
"layer_1": "correlations._correlate_protein_lbm",
|
||||
}
|
||||
}
|
||||
return build_lbm_protein_correlation_chart_payload(profile_id, max_lag)
|
||||
|
||||
|
||||
@router.get("/load-vitals-correlation")
|
||||
|
|
@ -1252,83 +1155,7 @@ def get_load_vitals_correlation_chart(
|
|||
Chart.js scatter chart with correlation data
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
||||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
||||
|
||||
def _abs_corr(c):
|
||||
if not c or c.get("correlation") is None:
|
||||
return -1.0
|
||||
try:
|
||||
return abs(float(c["correlation"]))
|
||||
except (TypeError, ValueError):
|
||||
return -1.0
|
||||
|
||||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
||||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
||||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
||||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
||||
if h_msg or r_msg:
|
||||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": msg,
|
||||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
||||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
||||
},
|
||||
}
|
||||
|
||||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
||||
corr_data = corr_hrv
|
||||
metric_name = "HRV"
|
||||
else:
|
||||
corr_data = corr_rhr
|
||||
metric_name = "RHR"
|
||||
|
||||
if not corr_data or corr_data.get("correlation") is None:
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
||||
},
|
||||
}
|
||||
|
||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
||||
correlation = corr_data.get('correlation', 0)
|
||||
|
||||
return {
|
||||
"chart_type": "scatter",
|
||||
"data": {
|
||||
"labels": [f"Load → {metric_name} (Lag {best_lag}d)"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Korrelation",
|
||||
"data": [{"x": best_lag, "y": correlation}],
|
||||
"backgroundColor": "#F59E0B",
|
||||
"borderColor": "#D97706",
|
||||
"borderWidth": 2,
|
||||
"pointRadius": 8
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": corr_data.get('confidence', 'low'),
|
||||
"correlation": round(float(correlation), 3),
|
||||
"best_lag_days": best_lag,
|
||||
"metric": metric_name,
|
||||
"interpretation": corr_data.get('interpretation', ''),
|
||||
"data_points": corr_data.get('data_points', 0),
|
||||
"lag_details": corr_data.get("lag_details"),
|
||||
"layer_1": "correlations._correlate_load_vitals",
|
||||
}
|
||||
}
|
||||
return build_load_vitals_correlation_chart_payload(profile_id, max_lag)
|
||||
|
||||
|
||||
@router.get("/recovery-performance")
|
||||
|
|
@ -1347,81 +1174,7 @@ def get_recovery_performance_chart(
|
|||
Chart.js bar chart with top drivers
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# Get top drivers (hindering/helpful factors)
|
||||
drivers = calculate_top_drivers(profile_id)
|
||||
|
||||
if not drivers or len(drivers) == 0:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Nicht genug Daten für Driver-Analyse"
|
||||
}
|
||||
}
|
||||
|
||||
# Separate hindering and helpful
|
||||
hindering = [d for d in drivers if d.get('impact', '') == 'hindering']
|
||||
helpful = [d for d in drivers if d.get('impact', '') == 'helpful']
|
||||
|
||||
# Take top 3 of each
|
||||
top_hindering = hindering[:3]
|
||||
top_helpful = helpful[:3]
|
||||
|
||||
labels = []
|
||||
values = []
|
||||
colors = []
|
||||
|
||||
for d in top_hindering:
|
||||
labels.append(f"❌ {d.get('factor', '')}")
|
||||
values.append(-abs(d.get('score', 0))) # Negative for hindering
|
||||
colors.append("#EF4444")
|
||||
|
||||
for d in top_helpful:
|
||||
labels.append(f"✅ {d.get('factor', '')}")
|
||||
values.append(abs(d.get('score', 0))) # Positive for helpful
|
||||
colors.append("#1D9E75")
|
||||
|
||||
if not labels:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "low",
|
||||
"data_points": 0,
|
||||
"message": "Keine signifikanten Treiber gefunden"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Impact Score",
|
||||
"data": values,
|
||||
"backgroundColor": colors,
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "medium",
|
||||
"hindering_count": len(top_hindering),
|
||||
"helpful_count": len(top_helpful),
|
||||
"total_factors": len(drivers)
|
||||
}
|
||||
}
|
||||
return build_recovery_performance_chart_payload(profile_id)
|
||||
|
||||
|
||||
# ── Health Endpoint ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from dashboard_layout_schema import (
|
|||
DashboardLayoutPayload,
|
||||
coalesce_effective_layout,
|
||||
default_layout_dict,
|
||||
merge_missing_catalog_widgets,
|
||||
)
|
||||
from widget_catalog import DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS
|
||||
|
||||
|
|
@ -56,3 +57,19 @@ def test_coalesce_valid_raw():
|
|||
custom, eff = coalesce_effective_layout(raw)
|
||||
assert custom is True
|
||||
assert eff == raw
|
||||
|
||||
|
||||
def test_merge_missing_catalog_widgets_keeps_order_and_fills_ids():
|
||||
raw = {
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{"id": "kpi_board", "enabled": True, "config": {}},
|
||||
{"id": "welcome", "enabled": False, "config": {}},
|
||||
],
|
||||
}
|
||||
merged = merge_missing_catalog_widgets(raw)
|
||||
assert [w["id"] for w in merged["widgets"][:2]] == ["kpi_board", "welcome"]
|
||||
assert {w["id"] for w in merged["widgets"]} == ALLOWED_WIDGET_IDS
|
||||
extra = [w for w in merged["widgets"] if w["id"] not in ("kpi_board", "welcome")]
|
||||
assert all(w["enabled"] is False for w in extra)
|
||||
DashboardLayoutPayload.model_validate(merged)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,196 @@ def test_body_chart_days_bounds():
|
|||
validate_widget_entry_config("body_overview", {"chart_days": 91})
|
||||
|
||||
|
||||
def test_body_history_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("body_history_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_kpis"] is True
|
||||
assert d["show_weight_chart"] is True
|
||||
assert d["kpi_detail"] == "compact"
|
||||
assert d["show_body_fat_chart"] is False
|
||||
|
||||
|
||||
def test_body_history_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("body_history_viz", {"chart_days": 60})
|
||||
assert d["chart_days"] == 60
|
||||
assert d["show_goals_strip"] is False
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_history_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_body_history_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"body_history_viz",
|
||||
{"show_kpis": False, "show_weight_chart": False},
|
||||
)
|
||||
|
||||
|
||||
def test_body_history_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("body_history_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_nutrition_history_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("nutrition_history_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_kpis"] is True
|
||||
assert d["show_kcal_vs_weight"] is True
|
||||
assert d["kpi_detail"] == "compact"
|
||||
assert d["show_calorie_balance_chart"] is False
|
||||
assert d["show_energy_protein_charts"] is False
|
||||
|
||||
|
||||
def test_nutrition_history_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("nutrition_history_viz", {"chart_days": 45})
|
||||
assert d["chart_days"] == 45
|
||||
assert d["show_goals_strip"] is False
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("nutrition_history_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_nutrition_history_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"nutrition_history_viz",
|
||||
{"show_kpis": False, "show_kcal_vs_weight": False, "show_macro_daily_bars": False, "show_macro_distribution_pair": False},
|
||||
)
|
||||
|
||||
|
||||
def test_nutrition_history_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("nutrition_history_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_fitness_history_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("fitness_history_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_kpis"] is True
|
||||
assert d["show_chart_training_volume"] is True
|
||||
assert d["kpi_detail"] == "compact"
|
||||
assert d["show_layer_meta"] is False
|
||||
assert d["show_chart_load_monitoring"] is False
|
||||
|
||||
|
||||
def test_fitness_history_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("fitness_history_viz", {"chart_days": 60})
|
||||
assert d["chart_days"] == 60
|
||||
assert d["show_progress_insights"] is False
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("fitness_history_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_fitness_history_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"fitness_history_viz",
|
||||
{
|
||||
"show_kpis": False,
|
||||
"show_progress_insights": False,
|
||||
"show_chart_training_volume": False,
|
||||
"show_chart_training_type_distribution": False,
|
||||
"show_chart_quality_sessions": False,
|
||||
"show_chart_load_monitoring": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_fitness_history_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("fitness_history_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_recovery_history_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("recovery_history_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_kpis"] is True
|
||||
assert d["show_chart_recovery_score"] is True
|
||||
assert d["kpi_detail"] == "compact"
|
||||
assert d["show_heart_context_card"] is False
|
||||
assert d["show_vitals_extra_trends"] is False
|
||||
|
||||
|
||||
def test_recovery_history_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("recovery_history_viz", {"chart_days": 42})
|
||||
assert d["chart_days"] == 42
|
||||
assert d["show_layer_meta"] is False
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("recovery_history_viz", {"chart_days": 3})
|
||||
|
||||
|
||||
def test_recovery_history_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"recovery_history_viz",
|
||||
{
|
||||
"show_kpis": False,
|
||||
"show_progress_insights": False,
|
||||
"show_heart_context_card": False,
|
||||
"show_vitals_extra_trends": False,
|
||||
"show_chart_recovery_score": False,
|
||||
"show_chart_sleep_quality": False,
|
||||
"show_chart_sleep_debt": False,
|
||||
"show_chart_hrv_rhr": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_history_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("recovery_history_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_history_overview_viz_empty_expands_defaults():
|
||||
d = validate_widget_entry_config("history_overview_viz", {})
|
||||
assert d["chart_days"] == 30
|
||||
assert d["show_confidence_banner"] is True
|
||||
assert d["show_section_body"] is True
|
||||
assert d["show_section_nutrition"] is True
|
||||
assert d["show_section_fitness"] is True
|
||||
assert d["show_section_recovery"] is True
|
||||
assert d["show_correlation_c1_c3"] is True
|
||||
assert d["show_drivers_c4"] is True
|
||||
|
||||
|
||||
def test_history_overview_viz_chart_days_and_merge():
|
||||
d = validate_widget_entry_config("history_overview_viz", {"chart_days": 60})
|
||||
assert d["chart_days"] == 60
|
||||
assert d["show_intro_blurb"] is True
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("history_overview_viz", {"chart_days": 5})
|
||||
|
||||
|
||||
def test_history_overview_viz_requires_visible_block():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config(
|
||||
"history_overview_viz",
|
||||
{
|
||||
"show_confidence_banner": False,
|
||||
"show_section_body": False,
|
||||
"show_section_nutrition": False,
|
||||
"show_section_fitness": False,
|
||||
"show_section_recovery": False,
|
||||
"show_correlation_c1_c3": False,
|
||||
"show_drivers_c4": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_history_overview_viz_legacy_show_area_summaries_maps_sections():
|
||||
d = validate_widget_entry_config(
|
||||
"history_overview_viz",
|
||||
{"show_area_summaries": False, "show_correlation_c1_c3": True},
|
||||
)
|
||||
assert d["show_section_body"] is False
|
||||
assert d["show_section_fitness"] is False
|
||||
assert d["show_correlation_c1_c3"] is True
|
||||
|
||||
|
||||
def test_history_overview_viz_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("history_overview_viz", {"evil": True})
|
||||
|
||||
|
||||
def test_welcome_config_rejected_unknown_key():
|
||||
with pytest.raises(ValueError):
|
||||
validate_widget_entry_config("welcome", {"x": 1})
|
||||
|
|
|
|||
|
|
@ -32,7 +32,9 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
|
|||
"version": 1,
|
||||
"widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)],
|
||||
}
|
||||
DashboardLayoutPayload.model_validate(small)
|
||||
# Gleicher Pfad wie get_stored_product_default_validated: Widget-Configs werden normalisiert
|
||||
# (z. B. body_history_viz / nutrition_history_viz / fitness_history_viz / recovery_history_viz: leere config → volle Defaults in to_stored_dict).
|
||||
expected = DashboardLayoutPayload.model_validate(small).to_stored_dict()
|
||||
|
||||
class _Cur:
|
||||
def execute(self, *a, **k):
|
||||
|
|
@ -42,4 +44,4 @@ def test_get_product_default_base_uses_db_when_valid(monkeypatch):
|
|||
return {"value": small}
|
||||
|
||||
monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur())
|
||||
assert get_product_default_base_dict(object()) == small
|
||||
assert get_product_default_base_dict(object()) == expected
|
||||
|
|
|
|||
|
|
@ -24,13 +24,13 @@ MODULE_VERSIONS = {
|
|||
"photos": "1.0.0",
|
||||
"insights": "1.3.0",
|
||||
"prompts": "1.1.0",
|
||||
"admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041)
|
||||
"admin": "1.4.1", # Produkt-Dashboard-Standard GET/DELETE: merge_missing_catalog_widgets
|
||||
"stats": "1.0.1",
|
||||
"exportdata": "1.1.0",
|
||||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.7.0", # Part 3: Inline Prompts (reference + inline mode)
|
||||
"app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog
|
||||
"app_dashboard": "1.17.1", # history_overview_viz: Bereichs-Kacheln einzeln per show_section_*
|
||||
"csv_import": "0.3.2", # Import-Fehler: enrich_row_error / freundlichere 500-Hinweise
|
||||
"admin_csv_templates": "0.3.0", # POST /validate + Speichern nur bei valid (422 + warnings in Response)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "body_history_viz",
|
||||
"title": "Körper (Verlauf-Bundle)",
|
||||
"description": "Layer-2b body-history-viz: schlanker Standard (KPI kompakt + Gewicht); optional Blöcke/Charts per config (show_* , kpi_detail); chart_days 7–90; Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "activity_overview",
|
||||
"title": "Aktivität",
|
||||
|
|
@ -94,6 +100,28 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "nutrition_history_viz",
|
||||
"title": "Ernährung (Verlauf-Bundle)",
|
||||
"description": "Layer-2b nutrition-history-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "fitness_history_viz",
|
||||
"title": "Fitness (Verlauf-Bundle)",
|
||||
"description": "Layer-2b fitness-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90; Feature activity_entries",
|
||||
"requires_feature": "activity_entries",
|
||||
},
|
||||
{
|
||||
"id": "recovery_history_viz",
|
||||
"title": "Erholung (Verlauf-Bundle)",
|
||||
"description": "Layer-2b recovery-dashboard-viz: schlanker Standard; Blöcke per show_* / kpi_detail; chart_days 7–90",
|
||||
},
|
||||
{
|
||||
"id": "history_overview_viz",
|
||||
"title": "Verlauf — Gesamtübersicht",
|
||||
"description": "Layer-2b history-overview-viz: Kurzinfos pro Bereich (show_section_body/nutrition/fitness/recovery) + C1–C4; chart_payloads; chart_days 7–90",
|
||||
},
|
||||
{
|
||||
"id": "recovery_charts_panel",
|
||||
"title": "Erholung — Charts R1–R5",
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ function Nav({ isAdmin }) {
|
|||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
state={item.to === '/history' ? { tab: 'overview' } : undefined}
|
||||
end={!!item.end}
|
||||
className={({ isActive }) =>
|
||||
'nav-item' +
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export default function DesktopSidebar({
|
|||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
state={item.to === '/history' ? { tab: 'overview' } : undefined}
|
||||
end={!!item.end}
|
||||
className={({ isActive }) =>
|
||||
'desktop-sidebar__link' +
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ import {
|
|||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor } from '../utils/interpret'
|
||||
import {
|
||||
FITNESS_HISTORY_VIZ_HISTORY_FULL,
|
||||
filterFitnessHistoryKpiTiles,
|
||||
normalizeFitnessHistoryVizConfig,
|
||||
} from '../widgetSystem/fitnessHistoryVizConfig'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const PERIODS = [
|
||||
|
|
@ -28,21 +33,35 @@ const PERIODS = [
|
|||
|
||||
/**
|
||||
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
|
||||
* @param {number} [props.externalPeriod] — feste Tage (z. B. Dashboard-Widget 7–90)
|
||||
* @param {boolean} [props.embedded]
|
||||
* @param {Record<string, unknown>} [props.visibility] — Dashboard-Config; undefined = voller Verlauf
|
||||
* @param {import('react').ReactNode} [props.footer]
|
||||
*/
|
||||
export default function FitnessDashboardOverview({
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector = false,
|
||||
externalPeriod,
|
||||
embedded = false,
|
||||
visibility,
|
||||
footer = null,
|
||||
}) {
|
||||
const nav = useNavigate()
|
||||
const [internalPeriod, setInternalPeriod] = useState(28)
|
||||
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
|
||||
const period = controlled ? periodProp : internalPeriod
|
||||
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
|
||||
const period =
|
||||
externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
|
||||
const setPeriod =
|
||||
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
|
||||
const [viz, setViz] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [err, setErr] = useState(null)
|
||||
|
||||
const display = visibility === undefined ? FITNESS_HISTORY_VIZ_HISTORY_FULL : normalizeFitnessHistoryVizConfig(visibility)
|
||||
const chartH = embedded ? 176 : 200
|
||||
const chartLoadH = embedded ? 200 : 220
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
|
@ -63,10 +82,13 @@ export default function FitnessDashboardOverview({
|
|||
}
|
||||
}, [period])
|
||||
|
||||
const outerClass = embedded ? '' : 'card section-gap'
|
||||
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Fitness-Übersicht</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
|
||||
<div className="spinner" style={{ margin: 24 }} />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -74,8 +96,8 @@ export default function FitnessDashboardOverview({
|
|||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Fitness-Übersicht</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
|
||||
<div style={{ color: 'var(--danger)' }}>{err}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -83,8 +105,8 @@ export default function FitnessDashboardOverview({
|
|||
|
||||
if (!viz?.has_activity_entries) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Fitness-Übersicht</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Fitness-Übersicht</div>}
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
|
||||
</p>
|
||||
|
|
@ -130,11 +152,14 @@ export default function FitnessDashboardOverview({
|
|||
}))
|
||||
const loadMeta = loadCh?.metadata || {}
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
sublabel:
|
||||
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
|
||||
}))
|
||||
const kpiTilesShown = display.show_kpis
|
||||
? filterFitnessHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
|
||||
: []
|
||||
|
||||
const insights = viz.progress_insights || []
|
||||
const eff = viz.effective_window_days
|
||||
|
|
@ -142,10 +167,11 @@ export default function FitnessDashboardOverview({
|
|||
const dTyp = viz.training_type_dist_days_used
|
||||
const loadDays = viz.load_chart_days_used
|
||||
|
||||
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
||||
const gridWrapStyle = { width: '100%', minWidth: 0 }
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && (
|
||||
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||
<span>Fitness-Übersicht</span>
|
||||
{showPeriodDropdown ? (
|
||||
|
|
@ -168,7 +194,15 @@ export default function FitnessDashboardOverview({
|
|||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{embedded && viz?.last_updated ? (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||
Letzte Aktivität {viz.last_updated}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{display.show_layer_meta ? (
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
|
||||
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
|
||||
|
|
@ -181,10 +215,11 @@ export default function FitnessDashboardOverview({
|
|||
) : null}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
|
||||
{kpiTilesShown.length > 0 ? <KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" /> : null}
|
||||
|
||||
{insights.length > 0 ? (
|
||||
{display.show_progress_insights && insights.length > 0 ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
|
|
@ -213,14 +248,17 @@ export default function FitnessDashboardOverview({
|
|||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 16,
|
||||
marginTop: 8,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{display.show_chart_training_volume ? (
|
||||
<div style={gridWrapStyle}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Trainingsvolumen (Minuten / Woche)
|
||||
</div>
|
||||
{volRows.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -245,17 +283,21 @@ export default function FitnessDashboardOverview({
|
|||
<Bar dataKey="min" fill="#1D9E75" radius={[3, 3, 0, 0]} name="Minuten" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Wochendaten im gewählten Fenster.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{display.show_chart_training_type_distribution ? (
|
||||
<div style={gridWrapStyle}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Training nach Kategorie
|
||||
</div>
|
||||
{pieData.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
|
|
@ -276,17 +318,21 @@ export default function FitnessDashboardOverview({
|
|||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{display.show_chart_quality_sessions ? (
|
||||
<div style={gridWrapStyle}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Qualitäts-Sessions (Schätzung)
|
||||
</div>
|
||||
{qualBar.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
|
|
@ -306,18 +352,22 @@ export default function FitnessDashboardOverview({
|
|||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
|
||||
{display.show_chart_load_monitoring ? (
|
||||
<div style={{ ...gridWrapStyle, gridColumn: '1 / -1', maxWidth: '100%' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Belastung (Proxy-Load · duration×RPE / Tag)
|
||||
</div>
|
||||
{loadRows.length >= 1 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartLoadH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
|
|
@ -333,6 +383,7 @@ export default function FitnessDashboardOverview({
|
|||
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
|
||||
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy)
|
||||
|
|
@ -342,7 +393,10 @@ export default function FitnessDashboardOverview({
|
|||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import RecoveryDashboardOverview from './RecoveryDashboardOverview'
|
||||
|
||||
/**
|
||||
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper für Dashboard-Widgets (days → period).
|
||||
* @deprecated Nutze direkt {@link RecoveryDashboardOverview}. Wrapper (days → externalPeriod).
|
||||
*/
|
||||
export default function RecoveryCharts({ days = 28 }) {
|
||||
return <RecoveryDashboardOverview period={days} hidePeriodSelector />
|
||||
return <RecoveryDashboardOverview externalPeriod={days} hidePeriodSelector />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
|
|||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import {
|
||||
RECOVERY_HISTORY_VIZ_HISTORY_FULL,
|
||||
filterRecoveryHistoryKpiTiles,
|
||||
normalizeRecoveryHistoryVizConfig,
|
||||
} from '../widgetSystem/recoveryHistoryVizConfig'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM.')
|
||||
|
|
@ -194,17 +199,32 @@ function ChartCard({ title, loading, error, children, description }) {
|
|||
|
||||
/**
|
||||
* Layer 2b: Erholung — ein Request GET /api/charts/recovery-dashboard-viz (recovery_metrics).
|
||||
* @param {number} [props.externalPeriod] — Widget: feste Tage (7–90)
|
||||
* @param {boolean} [props.embedded]
|
||||
* @param {Record<string, unknown>} [props.visibility] — Dashboard-Config; undefined = voller Verlauf
|
||||
* @param {import('react').ReactNode} [props.footer]
|
||||
*/
|
||||
export default function RecoveryDashboardOverview({
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector = false,
|
||||
externalPeriod,
|
||||
embedded = false,
|
||||
visibility,
|
||||
footer = null,
|
||||
}) {
|
||||
const nav = useNavigate()
|
||||
const [internalPeriod, setInternalPeriod] = useState(28)
|
||||
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
|
||||
const period = controlled ? periodProp : internalPeriod
|
||||
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
|
||||
const period =
|
||||
externalPeriod !== undefined ? externalPeriod : controlled ? periodProp : internalPeriod
|
||||
const setPeriod =
|
||||
externalPeriod !== undefined ? () => {} : controlled ? onPeriodChange : setInternalPeriod
|
||||
|
||||
const display =
|
||||
visibility === undefined ? RECOVERY_HISTORY_VIZ_HISTORY_FULL : normalizeRecoveryHistoryVizConfig(visibility)
|
||||
const chartH = embedded ? 176 : 200
|
||||
const chartHVitals = embedded ? 200 : 220
|
||||
|
||||
const [viz, setViz] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
|
@ -230,10 +250,13 @@ export default function RecoveryDashboardOverview({
|
|||
}
|
||||
}, [period])
|
||||
|
||||
const outerClass = embedded ? '' : 'card section-gap'
|
||||
const showPeriodDropdown = !hidePeriodSelector && externalPeriod === undefined && !controlled
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
|
||||
<div className="spinner" style={{ margin: 24 }} />
|
||||
</div>
|
||||
)
|
||||
|
|
@ -241,8 +264,8 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
|
||||
<div style={{ color: 'var(--danger)' }}>{err}</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -250,8 +273,8 @@ export default function RecoveryDashboardOverview({
|
|||
|
||||
if (!viz?.has_recovery_data) {
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Erholung & Vitalwerte</div>
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && <div className="card-title">Erholung & Vitalwerte</div>}
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||
{viz?.message || 'Noch keine Schlaf- oder Vitaldaten.'} Sobald du Schlaf oder morgendliche Vitalwerte erfasst
|
||||
oder importierst, erscheinen Auswertungen hier.
|
||||
|
|
@ -280,18 +303,19 @@ export default function RecoveryDashboardOverview({
|
|||
const vo2SectionInsights = sectionInsights.filter((s) => s.section === 'vo2')
|
||||
const heartSnapshotItems = ['resting_hr', 'hrv', 'blood_pressure'].map((k) => vitalItemsByKey[k]).filter(Boolean)
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
sublabel:
|
||||
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
|
||||
}))
|
||||
const kpiTilesShown = display.show_kpis
|
||||
? filterRecoveryHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
|
||||
: []
|
||||
const insights = viz.progress_insights || []
|
||||
const eff = viz.effective_window_days
|
||||
const cDays = viz.chart_days_used
|
||||
const vDays = viz.vital_matrix_days_used
|
||||
|
||||
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
||||
|
||||
const renderRecoveryScore = () => {
|
||||
if (!recoveryData || recoveryData.metadata?.confidence === 'insufficient') {
|
||||
return (
|
||||
|
|
@ -306,7 +330,8 @@ export default function RecoveryDashboardOverview({
|
|||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -341,6 +366,7 @@ export default function RecoveryDashboardOverview({
|
|||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.45 }}>
|
||||
KPI Recovery-Score (aktuell): <strong>{recoveryData.metadata.current_score}/100</strong> · Datenpunkte Kurve:{' '}
|
||||
{recoveryData.metadata.data_points}
|
||||
|
|
@ -364,7 +390,8 @@ export default function RecoveryDashboardOverview({
|
|||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -402,6 +429,7 @@ export default function RecoveryDashboardOverview({
|
|||
<Line yAxisId="right" type="monotone" dataKey="rhr" stroke="#3B82F6" strokeWidth={2} name="RHR (bpm)" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
HRV Ø {hrvRhrData.metadata.avg_hrv}ms · RHR Ø {hrvRhrData.metadata.avg_rhr}bpm
|
||||
</div>
|
||||
|
|
@ -424,7 +452,8 @@ export default function RecoveryDashboardOverview({
|
|||
}))
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -447,6 +476,7 @@ export default function RecoveryDashboardOverview({
|
|||
<Line yAxisId="right" type="monotone" dataKey="quality" stroke="#1D9E75" strokeWidth={2} name="Qualität (%)" dot={{ r: 2 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Ø {sleepData.metadata.avg_duration_hours}h Schlaf
|
||||
</div>
|
||||
|
|
@ -469,7 +499,8 @@ export default function RecoveryDashboardOverview({
|
|||
const curDebt = debtData.metadata?.current_debt_hours
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
|
|
@ -498,6 +529,7 @@ export default function RecoveryDashboardOverview({
|
|||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center' }}>
|
||||
Aktuelle Schuld: {curDebt != null ? Number(curDebt).toFixed(1) : '—'}h
|
||||
</div>
|
||||
|
|
@ -577,7 +609,7 @@ export default function RecoveryDashboardOverview({
|
|||
Ein Messpunkt ({formatAxisTick(m.last)}) — weiter erfassen, um einen Verlauf zu sehen.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: hasMa ? 220 : 200, minHeight: hasMa ? 220 : 200 }}>
|
||||
<div style={{ width: '100%', minWidth: 0, height: hasMa ? chartHVitals : chartH, minHeight: hasMa ? chartHVitals : chartH }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 4, right: 12, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
|
|
@ -632,7 +664,8 @@ export default function RecoveryDashboardOverview({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="card section-gap">
|
||||
<div className={outerClass || undefined}>
|
||||
{!embedded && (
|
||||
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
|
||||
<span>Erholung & Vitalwerte</span>
|
||||
{showPeriodDropdown ? (
|
||||
|
|
@ -654,15 +687,20 @@ export default function RecoveryDashboardOverview({
|
|||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_layer_meta ? (
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
||||
Daten-Layer Auswertung · Fenster ca. <strong>{eff}</strong> Tage · Chart-Horizont <strong>{cDays}</strong> Tage ·
|
||||
Vital-Snapshot <strong>{vDays}</strong> Tage.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" marginBottom={16} />
|
||||
{kpiTilesShown.length > 0 ? (
|
||||
<KpiTilesOverview tiles={kpiTilesShown} heading="Kennzahlen" marginBottom={16} />
|
||||
) : null}
|
||||
|
||||
{insights.length > 0 ? (
|
||||
{display.show_progress_insights && insights.length > 0 ? (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Überblick: Recovery & Schlaf
|
||||
|
|
@ -690,11 +728,14 @@ export default function RecoveryDashboardOverview({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{display.show_sleep_section_heading ? (
|
||||
<SectionHeading
|
||||
compactTop
|
||||
title="Schlaf & Erholung"
|
||||
hint="Recovery-Score und Schlaf im gleichen Zeitraum wie die Kennzahlen oben."
|
||||
/>
|
||||
) : null}
|
||||
{display.show_chart_recovery_score ? (
|
||||
<ChartCard
|
||||
title="HRV-Verlauf (kein Recovery-Score)"
|
||||
description={
|
||||
|
|
@ -704,6 +745,8 @@ export default function RecoveryDashboardOverview({
|
|||
>
|
||||
{renderRecoveryScore()}
|
||||
</ChartCard>
|
||||
) : null}
|
||||
{display.show_chart_sleep_quality ? (
|
||||
<ChartCard
|
||||
title="Schlaf: Dauer & Qualität"
|
||||
description={
|
||||
|
|
@ -714,6 +757,8 @@ export default function RecoveryDashboardOverview({
|
|||
>
|
||||
{renderSleepQuality()}
|
||||
</ChartCard>
|
||||
) : null}
|
||||
{display.show_chart_sleep_debt ? (
|
||||
<ChartCard
|
||||
title="Schlafschuld"
|
||||
description={
|
||||
|
|
@ -724,11 +769,15 @@ export default function RecoveryDashboardOverview({
|
|||
>
|
||||
{renderSleepDebt()}
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{display.show_heart_section_heading ? (
|
||||
<SectionHeading
|
||||
title="Herz & Kreislauf"
|
||||
hint="Text-Hinweise und Zonen-Snapshots zu Ruhepuls, HRV und Blutdruck; Verlauf nur im kombinierten Diagramm (keine zweite RHR/HRV-Linie unten)."
|
||||
/>
|
||||
) : null}
|
||||
{display.show_heart_context_card ? (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einordnung & Kontext</div>
|
||||
<HeartAutonomicGuide />
|
||||
|
|
@ -762,21 +811,30 @@ export default function RecoveryDashboardOverview({
|
|||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{display.show_chart_hrv_rhr ? (
|
||||
<ChartCard
|
||||
title="HRV & Ruhepuls — Zeitverlauf"
|
||||
description="Zwei Y-Achsen: HRV (ms, links), Ruhepuls (bpm, rechts). Gleicher Zeitraum wie die Charts oben."
|
||||
>
|
||||
{renderHrvRhr()}
|
||||
</ChartCard>
|
||||
) : null}
|
||||
|
||||
{display.show_vitals_extra_heading ? (
|
||||
<SectionHeading
|
||||
title="Weitere Vitalparameter (Verlauf)"
|
||||
hint="VO2max-Trendtexte erscheinen oberhalb des Diagramms. SpO2 und Atemfrequenz: Zonen zum letzten Snapshot unter dem Titel."
|
||||
/>
|
||||
) : null}
|
||||
{display.show_vitals_extra_trends ? (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Verläufe</div>
|
||||
{renderWeitereVitalVerlaeufe(vo2SectionInsights, vitalItemsByKey)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import BodyHistoryVizSection from '../history/BodyHistoryVizSection'
|
||||
import { useProfile } from '../../context/ProfileContext'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
import { normalizeBodyHistoryVizConfig } from '../../widgetSystem/bodyHistoryVizConfig'
|
||||
|
||||
/**
|
||||
* Verlauf → Körper als Dashboard-Widget: GET /charts/body-history-viz (Layer 2b), Umfang über Layout-Config.
|
||||
* @param {{ refreshTick?: number, bodyHistoryVizConfig?: Record<string, unknown> }} props
|
||||
*/
|
||||
export default function BodyHistoryVizWidget({ refreshTick = 0, bodyHistoryVizConfig }) {
|
||||
const nav = useNavigate()
|
||||
const { activeProfile } = useProfile()
|
||||
const cfg = normalizeBodyHistoryVizConfig(bodyHistoryVizConfig)
|
||||
const days = normalizeBodyChartDays(cfg.chart_days)
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Körper (Verlauf-Bundle)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>body-history-viz · {days} Tage</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'body' } })}>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<BodyHistoryVizSection
|
||||
key={`${refreshTick}-${days}`}
|
||||
profile={activeProfile}
|
||||
externalPeriod={days}
|
||||
embedded
|
||||
visibility={cfg}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import FitnessHistoryVizSection from '../history/FitnessHistoryVizSection'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
import { normalizeFitnessHistoryVizConfig } from '../../widgetSystem/fitnessHistoryVizConfig'
|
||||
|
||||
/**
|
||||
* Verlauf → Fitness als Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b), Umfang über Layout-Config.
|
||||
* @param {{ refreshTick?: number, fitnessHistoryVizConfig?: Record<string, unknown> }} props
|
||||
*/
|
||||
export default function FitnessHistoryVizWidget({ refreshTick = 0, fitnessHistoryVizConfig }) {
|
||||
const nav = useNavigate()
|
||||
const cfg = normalizeFitnessHistoryVizConfig(fitnessHistoryVizConfig)
|
||||
const days = normalizeBodyChartDays(cfg.chart_days)
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Fitness (Verlauf-Bundle)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>fitness-dashboard-viz · {days} Tage</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'activity' } })}>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<FitnessHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import NutritionHistoryVizSection from '../history/NutritionHistoryVizSection'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
import { normalizeNutritionHistoryVizConfig } from '../../widgetSystem/nutritionHistoryVizConfig'
|
||||
|
||||
/**
|
||||
* Verlauf → Ernährung als Dashboard-Widget: GET /charts/nutrition-history-viz (Layer 2b), Umfang über Layout-Config.
|
||||
* @param {{ refreshTick?: number, nutritionHistoryVizConfig?: Record<string, unknown> }} props
|
||||
*/
|
||||
export default function NutritionHistoryVizWidget({ refreshTick = 0, nutritionHistoryVizConfig }) {
|
||||
const nav = useNavigate()
|
||||
const cfg = normalizeNutritionHistoryVizConfig(nutritionHistoryVizConfig)
|
||||
const days = normalizeBodyChartDays(cfg.chart_days)
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Ernährung (Verlauf-Bundle)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>nutrition-history-viz · {days} Tage</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'nutrition' } })}>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<NutritionHistoryVizSection
|
||||
key={`${refreshTick}-${days}`}
|
||||
externalPeriod={days}
|
||||
embedded
|
||||
visibility={cfg}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ export default function RecoveryChartsPanelWidget({ refreshTick = 0, chartDays }
|
|||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} period={days} hidePeriodSelector />
|
||||
<RecoveryDashboardOverview key={`${refreshTick}-${days}`} externalPeriod={days} hidePeriodSelector />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import RecoveryHistoryVizSection from '../history/RecoveryHistoryVizSection'
|
||||
import { normalizeBodyChartDays } from '../../widgetSystem/bodyChartDays'
|
||||
import { normalizeRecoveryHistoryVizConfig } from '../../widgetSystem/recoveryHistoryVizConfig'
|
||||
|
||||
/**
|
||||
* Verlauf → Erholung als Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b), Umfang über Layout-Config.
|
||||
* @param {{ refreshTick?: number, recoveryHistoryVizConfig?: Record<string, unknown> }} props
|
||||
*/
|
||||
export default function RecoveryHistoryVizWidget({ refreshTick = 0, recoveryHistoryVizConfig }) {
|
||||
const nav = useNavigate()
|
||||
const cfg = normalizeRecoveryHistoryVizConfig(recoveryHistoryVizConfig)
|
||||
const days = normalizeBodyChartDays(cfg.chart_days)
|
||||
|
||||
return (
|
||||
<div className="card section-gap" style={{ marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'var(--text1)' }}>Erholung (Verlauf-Bundle)</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>recovery-dashboard-viz · {days} Tage</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '6px 12px' }} onClick={() => nav('/history', { state: { tab: 'activity' } })}>
|
||||
Verlauf →
|
||||
</button>
|
||||
</div>
|
||||
<RecoveryHistoryVizSection key={`${refreshTick}-${days}`} externalPeriod={days} embedded visibility={cfg} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
589
frontend/src/components/history/BodyHistoryVizSection.jsx
Normal file
589
frontend/src/components/history/BodyHistoryVizSection.jsx
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ComposedChart,
|
||||
} from 'recharts'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
import { api } from '../../utils/api'
|
||||
import { getStatusColor } from '../../utils/interpret'
|
||||
import KpiTilesOverview from '../KpiTilesOverview'
|
||||
import {
|
||||
BODY_HISTORY_VIZ_HISTORY_FULL,
|
||||
filterBodyHistoryKpiTiles,
|
||||
} from '../../widgetSystem/bodyHistoryVizConfig'
|
||||
import { EmptySection, NavToCaliper, NavToCircum, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||||
|
||||
dayjs.locale('de')
|
||||
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM')
|
||||
|
||||
/** Recharts: in schmalen Flex-Spalten sonst Breite 0. */
|
||||
function ChartFrame({ heightPx, children }) {
|
||||
return (
|
||||
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="body-history-viz__chart-frame">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function verdictShort(status) {
|
||||
if (status === 'good') return 'Gut'
|
||||
if (status === 'warn') return 'Hinweis'
|
||||
return 'Achtung'
|
||||
}
|
||||
|
||||
/** KPI-Kacheln aus Summary + Interpretationstiles — Trend aus Bundle ``weight.trend_kpi`` (Layer 2b). */
|
||||
function buildBodyKpiTiles({
|
||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
|
||||
}) {
|
||||
const tiles = []
|
||||
|
||||
if (summary.weight_kg != null) {
|
||||
const wt = weightTrendKpi || { verdict: 'Stabil', status: 'good' }
|
||||
const trendBits = trendPeriods.length
|
||||
? trendPeriods.map((t) => `${t.label} ${t.diff_kg > 0 ? '+' : ''}${t.diff_kg} kg`).join(' · ')
|
||||
: ''
|
||||
const hoverBody = [
|
||||
'Gewicht im gewählten Zeitraum (letzter Messwert).',
|
||||
avgAll != null ? `Durchschnitt: ${avgAll} kg` : null,
|
||||
minW != null && maxW != null ? `Min. / Max.: ${minW} – ${maxW} kg` : null,
|
||||
trendBits ? `Änderung: ${trendBits}` : null,
|
||||
goalW != null ? `Profil-Zielgewicht: ${goalW} kg` : null,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
tiles.push({
|
||||
key: 'weight',
|
||||
category: 'Gewicht',
|
||||
icon: '⚖️',
|
||||
value: `${summary.weight_kg} kg`,
|
||||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||||
verdict: wt.verdict,
|
||||
status: wt.status,
|
||||
hoverTop: 'Gewicht',
|
||||
hoverBody,
|
||||
keys: ['weight_aktuell', 'weight_trend'],
|
||||
})
|
||||
}
|
||||
|
||||
const kfRule = rules.find((r) => r.category === 'Körperfett')
|
||||
if (summary.body_fat_pct != null) {
|
||||
tiles.push({
|
||||
key: 'bf',
|
||||
category: 'Körperfett',
|
||||
icon: '🫧',
|
||||
value: `${summary.body_fat_pct}%`,
|
||||
valueColor: kfRule ? getStatusColor(kfRule.status) : undefined,
|
||||
sublabel: summary.bf_category_label || '',
|
||||
verdict: verdictShort(kfRule?.status || 'good'),
|
||||
status: kfRule?.status || 'good',
|
||||
hoverTop: kfRule?.title || 'Körperfettanteil',
|
||||
hoverBody: [kfRule?.detail, kfRule?.related_placeholder_keys?.length ? `Registry: ${kfRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const mmRule = rules.find((r) => r.category === 'Muskelmasse')
|
||||
if (summary.lean_mass_kg != null || summary.ffmi != null) {
|
||||
const valParts = []
|
||||
if (summary.lean_mass_kg != null) valParts.push(`${summary.lean_mass_kg} kg`)
|
||||
if (summary.ffmi != null) valParts.push(`FFMI ${summary.ffmi}`)
|
||||
tiles.push({
|
||||
key: 'lean_ffmi',
|
||||
category: 'Magermasse',
|
||||
icon: '💪',
|
||||
value: valParts.join(' · ') || '—',
|
||||
sublabel: 'Lean / FFMI',
|
||||
verdict: mmRule ? verdictShort(mmRule.status) : '—',
|
||||
status: mmRule?.status || 'good',
|
||||
hoverTop: mmRule?.title || 'Muskelmasse',
|
||||
hoverBody: [mmRule?.detail, mmRule?.related_placeholder_keys?.length ? `Registry: ${mmRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const bmiRule = rules.find((r) => r.category === 'BMI')
|
||||
if (bmiRule) {
|
||||
tiles.push({
|
||||
key: 'bmi',
|
||||
category: 'BMI',
|
||||
icon: '📋',
|
||||
value: bmiRule.value || '—',
|
||||
sublabel: 'Body-Mass-Index',
|
||||
verdict: verdictShort(bmiRule.status),
|
||||
status: bmiRule.status,
|
||||
hoverTop: bmiRule.title,
|
||||
hoverBody: [bmiRule.detail, bmiRule.related_placeholder_keys?.length ? `Registry: ${bmiRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whrRule = rules.find((r) => r.category === 'Fettverteilung')
|
||||
if (summary.whr != null && whrRule) {
|
||||
tiles.push({
|
||||
key: 'whr',
|
||||
category: 'Fettverteilung',
|
||||
icon: '📐',
|
||||
value: String(summary.whr),
|
||||
sublabel: 'WHR · Taille ÷ Hüfte',
|
||||
verdict: verdictShort(whrRule.status),
|
||||
status: whrRule.status,
|
||||
hoverTop: whrRule.title || 'Waist-Hip-Ratio',
|
||||
hoverBody: [whrRule.detail, whrRule.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const whtrRule = rules.find((r) => r.category === 'Taille/Größe')
|
||||
if (summary.whtr != null && whtrRule) {
|
||||
tiles.push({
|
||||
key: 'whtr',
|
||||
category: 'Taille/Größe',
|
||||
icon: '📏',
|
||||
value: String(summary.whtr),
|
||||
sublabel: 'WHtR · Taille ÷ Größe',
|
||||
verdict: verdictShort(whtrRule.status),
|
||||
status: whtrRule.status,
|
||||
hoverTop: whtrRule.title || 'Waist-to-Height-Ratio',
|
||||
hoverBody: [whtrRule.detail, whtrRule.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
const lastRule = rules.find((r) => r.category.startsWith('Seit letzter'))
|
||||
if (lastRule) {
|
||||
tiles.push({
|
||||
key: 'delta',
|
||||
category: 'Messvergleich',
|
||||
icon: '📊',
|
||||
value: lastRule.value || '—',
|
||||
sublabel: 'seit Vorperiode',
|
||||
verdict: verdictShort(lastRule.status),
|
||||
status: lastRule.status,
|
||||
hoverTop: lastRule.title,
|
||||
hoverBody: [lastRule.detail, lastRule.related_placeholder_keys?.length ? `Registry: ${lastRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
})
|
||||
}
|
||||
|
||||
return tiles
|
||||
}
|
||||
|
||||
function BodyGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.body || []).filter((g) => g.status === 'active').slice(0, 4)
|
||||
if (!goals.length) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperbezogene Ziele</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
|
||||
Ziele <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{goals.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
flex: '1 1 140px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{g.name || g.label_de || g.goal_type}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
|
||||
{g.unit ? ` ${g.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf → Körper: nur GET /api/charts/body-history-viz (Layer 2b).
|
||||
* @param {object} props
|
||||
* @param {object} props.profile — aktives Profil (Zielgewicht-Fallback)
|
||||
* @param {number} [props.externalPeriod] — festes Fenster (Dashboard); sonst interner Zeitraum + PeriodSelector
|
||||
* @param {import('react').ReactNode} [props.footer] — z. B. KI-InsightBox im Verlauf
|
||||
* @param {boolean} [props.embedded] — true im Dashboard-Widget: keine große Section-Überschrift (Karte hat eigenen Titel)
|
||||
* @param {object} [props.visibility] — Sichtbarkeit (Dashboard-Config); fehlt → wie Verlauf (alles an, kpi_detail full)
|
||||
*/
|
||||
export default function BodyHistoryVizSection({ profile, externalPeriod, footer = null, embedded = false, visibility }) {
|
||||
const display = visibility === undefined ? BODY_HISTORY_VIZ_HISTORY_FULL : visibility
|
||||
const chartHMain = embedded ? 176 : 200
|
||||
const chartHSecondary = embedded ? 152 : 170
|
||||
const chartHIndex = embedded ? 160 : 180
|
||||
const chartHFallback = embedded ? 140 : 150
|
||||
|
||||
const [internalPeriod, setInternalPeriod] = useState(90)
|
||||
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
|
||||
const showPeriodSelector = externalPeriod === undefined
|
||||
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
const [vizLoading, setVizLoading] = useState(true)
|
||||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!display.show_goals_strip) {
|
||||
setGroupedGoals({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
.then((g) => {
|
||||
if (!cancelled) setGroupedGoals(g)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroupedGoals({})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [display.show_goals_strip])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setVizLoading(true)
|
||||
setVizError(null)
|
||||
api.getBodyHistoryViz(period)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setViz(data)
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) {
|
||||
setVizError(e.message || 'Laden fehlgeschlagen')
|
||||
setVizLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const w = viz?.weight
|
||||
const cal = viz?.caliper
|
||||
const circ = viz?.circumference
|
||||
const summary = viz?.summary || {}
|
||||
|
||||
const wCd = (w?.series || []).map((row) => ({
|
||||
date: fmtDate(row.date),
|
||||
weight: row.weight,
|
||||
avg7: row.avg7,
|
||||
avg14: row.avg14,
|
||||
}))
|
||||
const hasWeight = (w?.data_points || 0) >= 2
|
||||
const avgAll = w?.overall_avg_kg
|
||||
const minW = w?.min_kg
|
||||
const maxW = w?.max_kg
|
||||
const trendPeriods = w?.trend_periods || []
|
||||
|
||||
const bfCd = (cal?.series || []).map((s) => ({
|
||||
date: fmtDate(s.date),
|
||||
bf: s.body_fat_pct,
|
||||
}))
|
||||
|
||||
const propChartData = (circ?.proportion_series || []).map((p) => ({
|
||||
date: fmtDate(p.date),
|
||||
vTaper: p.v_taper_cm,
|
||||
vTaper_avg: p.v_taper_cm_avg,
|
||||
belly: p.belly_cm,
|
||||
}))
|
||||
const showBellyOnProp = propChartData.some((d) => d.belly != null && d.belly !== undefined)
|
||||
|
||||
const idxSeriesRaw = circ?.index_series || []
|
||||
const idxSeries = idxSeriesRaw.map((row) => ({ ...row, date: fmtDate(row.date) }))
|
||||
const idxOk = circ?.index_usable
|
||||
|
||||
const cirCd = (circ?.fallback_multiline || []).map((r) => ({
|
||||
date: fmtDate(r.date),
|
||||
waist: r.waist,
|
||||
hip: r.hip,
|
||||
belly: r.belly,
|
||||
}))
|
||||
|
||||
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
||||
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
||||
|
||||
const rules = (viz?.interpretation_tiles || []).map((t) => ({
|
||||
category: t.category,
|
||||
icon: t.icon,
|
||||
status: t.status,
|
||||
title: t.title,
|
||||
detail: t.detail,
|
||||
value: t.value,
|
||||
related_placeholder_keys: t.related_placeholder_keys,
|
||||
}))
|
||||
|
||||
const kpiTiles = buildBodyKpiTiles({
|
||||
summary,
|
||||
rules,
|
||||
trendPeriods,
|
||||
minW,
|
||||
maxW,
|
||||
avgAll,
|
||||
dataPoints: w?.data_points,
|
||||
weightTrendKpi: w?.trend_kpi,
|
||||
goalW,
|
||||
})
|
||||
const kpiTilesShown = display.show_kpis ? filterBodyHistoryKpiTiles(kpiTiles, display.kpi_detail || 'full') : []
|
||||
|
||||
const hasAnyData =
|
||||
(w?.data_points > 0) ||
|
||||
(cal?.data_points > 0) ||
|
||||
(cirCd.length > 0)
|
||||
|
||||
if (vizLoading && !viz) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
<div className="empty-state">
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (vizError) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
<div style={{ padding: 16, color: 'var(--danger)' }}>{vizError}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" />}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
<EmptySection text="Noch keine Körperdaten im gewählten Zeitraum." to="/weight" toLabel="Gewicht eintragen" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="⚖️ Körper" lastUpdated={viz?.last_updated} />}
|
||||
{embedded && viz?.last_updated && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
|
||||
</div>
|
||||
)}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
|
||||
{display.show_goals_strip && <BodyGoalsStrip grouped={groupedGoals} />}
|
||||
|
||||
{display.show_intro_blurb && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Daten und Kennzahlen aus dem Backend-Bundle (gleiche Quelle wie Platzhalter). Training: <strong>Verlauf → Fitness</strong>.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{display.show_layer_meta && viz?.meta?.layer_2a_alignment && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
|
||||
{viz.meta.layer_2a_alignment}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
|
||||
|
||||
{vizLoading && <div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>}
|
||||
|
||||
{display.show_weight_chart && hasWeight && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>
|
||||
Gewicht · {w?.data_points || 0} Einträge
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => { window.location.href = '/weight' }}>
|
||||
Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHMain}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={wCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(wCd.length / 6) - 1)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{avgAll != null && (
|
||||
<ReferenceLine y={avgAll} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} label={{ value: `Ø ${avgAll}`, fontSize: 9, fill: 'var(--text3)', position: 'right' }} />
|
||||
)}
|
||||
{goalW != null && (
|
||||
<ReferenceLine y={goalW} stroke="var(--accent)" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalW}kg`, fontSize: 9, fill: 'var(--accent)', position: 'right' }} />
|
||||
)}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} kg`, n === 'weight' ? 'Täglich' : n === 'avg7' ? 'Ø 7 Tage' : 'Ø 14 Tage']} />
|
||||
<Line type="monotone" dataKey="weight" stroke="#378ADD88" strokeWidth={1.5} dot={{ r: 3, fill: '#378ADD', stroke: '#378ADD', strokeWidth: 1 }} activeDot={{ r: 5 }} name="weight" />
|
||||
<Line type="monotone" dataKey="avg7" stroke="#378ADD" strokeWidth={2.5} dot={false} name="avg7" />
|
||||
<Line type="monotone" dataKey="avg14" stroke="#1D9E75" strokeWidth={2} dot={false} strokeDasharray="6 3" name="avg14" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD88', verticalAlign: 'middle', marginRight: 3 }} />● Täglich</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#378ADD', verticalAlign: 'middle', marginRight: 3 }} />Ø 7T</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||||
<svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 3" /></svg>
|
||||
Ø 14T
|
||||
</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: 'var(--text3)', verticalAlign: 'middle', marginRight: 3, borderTop: '2px dashed' }} />Ø Gesamt</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_body_fat_chart && bfCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Körperfett (Caliper)</div>
|
||||
<NavToCaliper />
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHSecondary}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={bfCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v) => [`${v}%`, 'KF%']} />
|
||||
{goalBf != null && <ReferenceLine y={goalBf} stroke="#D85A30" strokeDasharray="5 3" strokeWidth={1.5} label={{ value: `Ziel ${goalBf}%`, fontSize: 9, fill: '#D85A30', position: 'right' }} />}
|
||||
<Line type="monotone" dataKey="bf" stroke="#D85A30" strokeWidth={2.5} dot={{ r: 4, fill: '#D85A30' }} name="bf" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>Magermasse aus Gewicht und KF% — zweite Kurve entfällt.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_proportion_chart && propChartData.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Silhouette & Proportion</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginTop: 4 }}>
|
||||
<strong>V-Taper (Brust − Taille)</strong> in cm.
|
||||
{showBellyOnProp && (
|
||||
<>
|
||||
<strong> Bauch</strong> (rechte Achse).
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHMain}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={propChartData} margin={{ top: 4, right: showBellyOnProp ? 4 : 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="taper" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{showBellyOnProp && <YAxis yAxisId="belly" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />}
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => {
|
||||
if (name === 'vTaper' || name === 'vTaper_avg') return [`${v} cm`, name === 'vTaper_avg' ? 'Ø V-Taper (3 Messungen)' : 'Brust − Taille']
|
||||
if (name === 'belly') return [`${v} cm`, 'Bauch']
|
||||
return [v, name]
|
||||
}}
|
||||
/>
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper" stroke="#1D9E75" strokeWidth={2} dot={{ r: 3 }} name="vTaper" />
|
||||
<Line yAxisId="taper" type="monotone" dataKey="vTaper_avg" stroke="#1D9E75" strokeWidth={1.5} strokeDasharray="5 4" dot={false} name="vTaper_avg" />
|
||||
{showBellyOnProp && <Line yAxisId="belly" type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} connectNulls name="belly" />}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust − Taille</span>
|
||||
<span><span style={{ display: 'inline-flex', alignItems: 'center', gap: 3 }}><svg width="14" height="4"><line x1="0" y1="2" x2="14" y2="2" stroke="#1D9E75" strokeWidth="2" strokeDasharray="5 4" /></svg></span>gleitender Mittelwert</span>
|
||||
{showBellyOnProp && <span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch (cm)</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_circumference_index_chart && idxOk && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Relative Entwicklung der Umfänge</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4, lineHeight: 1.4 }}>Index 100 = erste Messung im Zeitraum.</div>
|
||||
</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHIndex}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={idxSeries} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<ReferenceLine y={100} stroke="var(--text3)" strokeDasharray="4 4" strokeWidth={1} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} Index`, n === 'chest_idx' ? 'Brust' : n === 'waist_idx' ? 'Taille' : 'Bauch']} />
|
||||
{idxSeries.some((d) => d.chest_idx != null) && <Line type="monotone" dataKey="chest_idx" stroke="#1D9E75" strokeWidth={2} dot={{ r: 2 }} connectNulls name="chest_idx" />}
|
||||
{idxSeries.some((d) => d.waist_idx != null) && <Line type="monotone" dataKey="waist_idx" stroke="#EF9F27" strokeWidth={2} dot={{ r: 2 }} connectNulls name="waist_idx" />}
|
||||
{idxSeries.some((d) => d.belly_idx != null) && <Line type="monotone" dataKey="belly_idx" stroke="#D4537E" strokeWidth={2} dot={{ r: 2 }} connectNulls name="belly_idx" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 6, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#1D9E75', verticalAlign: 'middle', marginRight: 3 }} />Brust</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#EF9F27', verticalAlign: 'middle', marginRight: 3 }} />Taille</span>
|
||||
<span><span style={{ display: 'inline-block', width: 12, height: 2, background: '#D4537E', verticalAlign: 'middle', marginRight: 3 }} />Bauch</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_circumference_lines_chart && propChartData.length < 2 && cirCd.length >= 2 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Umfänge (Taille / Hüfte / Bauch)</div>
|
||||
<NavToCircum />
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>Mit Brust- und Taillenumfang erscheint die Proportionen-Ansicht oben.</div>
|
||||
<ChartFrame heightPx={chartHFallback}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={cirCd} margin={{ top: 4, right: 8, bottom: 0, left: -20 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, n) => [`${v} cm`, n]} />
|
||||
<Line type="monotone" dataKey="waist" stroke="#EF9F27" strokeWidth={2} dot={{ r: 3 }} name="Taille" />
|
||||
<Line type="monotone" dataKey="hip" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3 }} name="Hüfte" />
|
||||
{cirCd.some((d) => d.belly) && <Line type="monotone" dataKey="belly" stroke="#D4537E" strokeWidth={2} dot={{ r: 3 }} name="Bauch" />}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/history/FitnessHistoryVizSection.jsx
Normal file
33
frontend/src/components/history/FitnessHistoryVizSection.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import FitnessDashboardOverview from '../FitnessDashboardOverview'
|
||||
|
||||
/**
|
||||
* Verlauf → Fitness bzw. Dashboard-Widget: GET /charts/fitness-dashboard-viz (Layer 2b).
|
||||
* @param {number} [props.externalPeriod] — Widget: feste Tage (7–90)
|
||||
* @param {number} [props.period] — Verlauf: gesteuerter Zeitraum (inkl. 9999)
|
||||
* @param {(n: number) => void} [props.onPeriodChange]
|
||||
* @param {boolean} [props.hidePeriodSelector]
|
||||
* @param {boolean} [props.embedded]
|
||||
* @param {Record<string, unknown>} [props.visibility] — undefined = volle Übersicht (wie bisher)
|
||||
* @param {import('react').ReactNode} [props.footer]
|
||||
*/
|
||||
export default function FitnessHistoryVizSection({
|
||||
externalPeriod,
|
||||
period,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector,
|
||||
embedded,
|
||||
visibility,
|
||||
footer,
|
||||
}) {
|
||||
return (
|
||||
<FitnessDashboardOverview
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
hidePeriodSelector={hidePeriodSelector}
|
||||
externalPeriod={externalPeriod}
|
||||
embedded={embedded}
|
||||
visibility={visibility}
|
||||
footer={footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
535
frontend/src/components/history/HistoryOverviewVizSection.jsx
Normal file
535
frontend/src/components/history/HistoryOverviewVizSection.jsx
Normal file
|
|
@ -0,0 +1,535 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
ComposedChart,
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
Line,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { api } from '../../utils/api'
|
||||
import { getStatusColor, getStatusBg } from '../../utils/interpret'
|
||||
import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||||
import { HISTORY_OVERVIEW_VIZ_PAGE_FULL, normalizeHistoryOverviewVizConfig } from '../../widgetSystem/historyOverviewVizConfig'
|
||||
|
||||
function overviewSectionTone(sec) {
|
||||
const kpis = sec.kpi_short || []
|
||||
if (kpis.some((k) => k.status === 'bad')) return 'bad'
|
||||
if (kpis.some((k) => k.status === 'warn')) return 'warn'
|
||||
const interp = sec.interpretation_short || []
|
||||
if (interp.some((x) => x.status === 'bad')) return 'bad'
|
||||
if (interp.some((x) => x.status === 'warn')) return 'warn'
|
||||
const heur = sec.heuristic_short || []
|
||||
if (heur.some((h) => h.status === 'warn')) return 'warn'
|
||||
return 'good'
|
||||
}
|
||||
|
||||
function overviewConfidenceUi(conf) {
|
||||
if (conf === 'high') return { label: 'Datenlage: gut', tone: 'good', hint: 'Ausreichend Messpunkte für sinnvolle Kurzinfos.' }
|
||||
if (conf === 'medium') return { label: 'Datenlage: mittel', tone: 'warn', hint: 'Einzelne Bereiche sind noch dünn besetzt.' }
|
||||
return { label: 'Datenlage: dünn', tone: 'bad', hint: 'Mehr Einträge verbessern die Aussagekraft.' }
|
||||
}
|
||||
|
||||
function chartJsScatterPoints(payload) {
|
||||
const raw = payload?.data?.datasets?.[0]?.data || []
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
|
||||
}
|
||||
|
||||
function lagDetailsToCurve(meta) {
|
||||
let ld = meta?.lag_details
|
||||
if (!Array.isArray(ld) || ld.length === 0) {
|
||||
const m = String(meta?.metric || '').toUpperCase()
|
||||
if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv
|
||||
else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr
|
||||
else {
|
||||
const h = meta?.lag_details_hrv
|
||||
const r = meta?.lag_details_rhr
|
||||
const hl = Array.isArray(h) ? h.length : 0
|
||||
const rl = Array.isArray(r) ? r.length : 0
|
||||
if (hl >= rl && hl > 0) ld = h
|
||||
else if (rl > 0) ld = r
|
||||
else ld = []
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(ld) || ld.length === 0) return []
|
||||
return ld
|
||||
.map((d) => ({
|
||||
lag: Number(d?.lag),
|
||||
r: d?.r == null || d?.r === '' ? null : Number(d.r),
|
||||
n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null,
|
||||
}))
|
||||
.filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r))
|
||||
.sort((a, b) => a.lag - b.lag)
|
||||
}
|
||||
|
||||
function driverBarFromStatus(st) {
|
||||
const s = String(st || '').toLowerCase()
|
||||
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
||||
if (s.includes('förder') || s.includes('foerder')) return { v: 1, fill: 'var(--accent)' }
|
||||
return { v: 0.15, fill: '#6B7280' }
|
||||
}
|
||||
|
||||
function chartJsBarRows(payload, fallbackDrivers) {
|
||||
const labels = payload?.data?.labels || []
|
||||
const values = payload?.data?.datasets?.[0]?.data || []
|
||||
const colors = payload?.data?.datasets?.[0]?.backgroundColor
|
||||
if (labels.length && values.length) {
|
||||
return labels.map((name, i) => ({
|
||||
name: name.length > 42 ? `${name.slice(0, 40)}…` : name,
|
||||
value: Number(values[i]),
|
||||
fill: Array.isArray(colors) ? colors[i] : Number(values[i]) < 0 ? '#EF4444' : '#1D9E75',
|
||||
}))
|
||||
}
|
||||
if (fallbackDrivers?.length) {
|
||||
return fallbackDrivers.map((d) => {
|
||||
const { v, fill } = driverBarFromStatus(d.status)
|
||||
return {
|
||||
name: String(d.factor || '—').length > 40 ? `${String(d.factor).slice(0, 38)}…` : String(d.factor || '—'),
|
||||
value: v,
|
||||
fill,
|
||||
subtitle: d.reason,
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function CorrelationScatterTile({ title, accent, payload }) {
|
||||
const meta = payload?.metadata || {}
|
||||
const pts = chartJsScatterPoints(payload)
|
||||
const curve = lagDetailsToCurve(meta)
|
||||
const hasChart = pts.length > 0 && meta.correlation != null
|
||||
const r = Number(meta.correlation)
|
||||
const strength =
|
||||
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
||||
const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null
|
||||
const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
padding: 10,
|
||||
borderLeft: `4px solid ${getStatusColor(strength)}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
||||
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
|
||||
{meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''}
|
||||
{meta.metric ? ` · ${meta.metric}` : ''}
|
||||
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
||||
</div>
|
||||
{!hasChart ? (
|
||||
<>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: curve.length ? 8 : 0 }}>
|
||||
{meta.message || 'Keine Daten für diese Korrelation.'}
|
||||
</div>
|
||||
{curve.length > 0 && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung.
|
||||
</div>
|
||||
)}
|
||||
{curve.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} label={{ value: 'Lag (T)', fontSize: 9, fill: 'var(--text3)', offset: -2 }} />
|
||||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} label={{ value: 'r', fontSize: 9, fill: 'var(--text3)', angle: -90 }} />
|
||||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||||
/>
|
||||
<Line type="monotone" dataKey="r" stroke={accent} strokeWidth={2} dot={{ r: 3, fill: accent }} isAnimationActive={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</>
|
||||
) : curve.length >= 1 ? (
|
||||
<>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)', marginBottom: 4 }}>
|
||||
Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag.
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={132}>
|
||||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} />
|
||||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
||||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="r"
|
||||
stroke={accent}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={(props) => {
|
||||
const { cx, cy, payload: pl } = props
|
||||
if (cx == null || cy == null || !pl) return null
|
||||
const isBest = bestLag != null && Number(pl.lag) === bestLag
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={isBest ? 6 : 3.5}
|
||||
fill={isBest ? 'var(--surface)' : accent}
|
||||
stroke={isBest ? accent : 'none'}
|
||||
strokeWidth={isBest ? 2.5 : 0}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={118}>
|
||||
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||
<XAxis type="number" dataKey="x" domain={[0, 28]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||
<YAxis type="number" dataKey="y" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
||||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }} />
|
||||
<Scatter name="r" data={pts} fill={accent} />
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
{meta.interpretation ? (
|
||||
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 6, lineHeight: 1.4 }}>{meta.interpretation}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DriversImpactTile({ payload, driversFallback }) {
|
||||
const meta = payload?.metadata || {}
|
||||
const rows = chartJsBarRows(payload, driversFallback)
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 12, borderLeft: '4px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Treiber-Daten.'}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const h = Math.min(220, Math.max(96, rows.length * 34))
|
||||
return (
|
||||
<div className="card" style={{ padding: 10, borderLeft: '4px solid var(--accent)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, marginBottom: 6 }}>C4 Einflussfaktoren</div>
|
||||
<ResponsiveContainer width="100%" height={h}>
|
||||
<BarChart data={rows} layout="vertical" margin={{ left: 2, right: 6, top: 2, bottom: 2 }}>
|
||||
<XAxis type="number" domain={[-1.2, 1.2]} tick={{ fontSize: 9 }} />
|
||||
<YAxis type="category" dataKey="name" width={112} tick={{ fontSize: 9, fill: 'var(--text2)' }} />
|
||||
<Tooltip
|
||||
content={({ active, payload: pp }) => {
|
||||
if (!active || !pp?.length) return null
|
||||
const p = pp[0].payload
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: '8px 10px',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
maxWidth: 280,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{p.name}</div>
|
||||
{p.subtitle ? <div style={{ marginTop: 4, color: 'var(--text2)', lineHeight: 1.4 }}>{p.subtitle}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{rows.map((e, i) => (
|
||||
<Cell key={i} fill={e.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf «Gesamt» / Dashboard-Widget: Layer-2b history-overview-viz (+ chart_payloads 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
|
||||
|
||||
const sectionTileEnabled = (id) => {
|
||||
if (id === 'body') return vis.show_section_body
|
||||
if (id === 'nutrition') return vis.show_section_nutrition
|
||||
if (id === 'fitness') return vis.show_section_fitness
|
||||
if (id === 'recovery') return vis.show_section_recovery
|
||||
return true
|
||||
}
|
||||
const wantsAnySectionTile =
|
||||
vis.show_section_body ||
|
||||
vis.show_section_nutrition ||
|
||||
vis.show_section_fitness ||
|
||||
vis.show_section_recovery
|
||||
const visibleSections = wantsAnySectionTile ? sections.filter((s) => sectionTileEnabled(s.id)) : []
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="📊 Gesamtansicht" />}
|
||||
{!hidePeriodSelector && externalPeriod == null && <PeriodSelector value={period} onChange={setPeriod} />}
|
||||
|
||||
{vis.show_confidence_banner && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 14,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
background: getStatusBg(confUi.tone),
|
||||
borderLeft: `5px solid ${getStatusColor(confUi.tone)}`,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 20, lineHeight: 1 }}>{confUi.tone === 'good' ? '●' : confUi.tone === 'warn' ? '◐' : '○'}</span>
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text1)' }}>{confUi.label}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text2)', marginTop: 2 }}>{confUi.hint}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vis.show_intro_blurb && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||
KPIs und Texte kommen aus den Layer-2b-Bundles (Körper, Ernährung, Fitness, Erholung).{' '}
|
||||
<strong>Ehem. «Korrelation»-Charts</strong> (Bilanz, Protein/Mager, Kurz-Einordnung) liegen unter{' '}
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => navigate('/history', { state: { tab: 'nutrition' } })}>
|
||||
Ernährung
|
||||
</button>
|
||||
. Die Kacheln C1–C4 entsprechen denselben Chart.js-Payloads wie <code style={{ fontSize: 10 }}>/api/charts/*</code> (bei aktuellem Backend im Overview-Bundle enthalten).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{wantsAnySectionTile && (visibleSections.length === 0 ? (
|
||||
<EmptySection text="Keine Bereichsdaten." />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: 10 }}>
|
||||
{visibleSections.map((sec) => {
|
||||
const tone = overviewSectionTone(sec)
|
||||
const stripe = getStatusColor(tone)
|
||||
const badgeBg = getStatusBg(tone)
|
||||
return (
|
||||
<div
|
||||
key={sec.id}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `5px solid ${stripe}`,
|
||||
background: 'var(--surface)',
|
||||
padding: '12px 12px 12px 14px',
|
||||
boxShadow: tone === 'bad' ? '0 0 0 1px rgba(216,90,48,0.12)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8, marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: 30,
|
||||
height: 30,
|
||||
borderRadius: 10,
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: stripe,
|
||||
background: badgeBg,
|
||||
}}
|
||||
>
|
||||
{tone === 'good' ? '✓' : tone === 'warn' ? '!' : '!!'}
|
||||
</span>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{sec.title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: 11, padding: '4px 10px', flexShrink: 0 }}
|
||||
onClick={() => navigate('/history', { state: { tab: sec.tab_id } })}
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 10, lineHeight: 1.45 }}>{sec.summary_line}</div>
|
||||
|
||||
{(sec.kpi_short || []).length > 0 && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(128px, 1fr))', gap: 8, marginBottom: 8 }}>
|
||||
{(sec.kpi_short || []).map((k, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
borderRadius: 10,
|
||||
background: getStatusBg(k.status || 'good'),
|
||||
border: `1px solid ${getStatusColor(k.status || 'good')}55`,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 9, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{k.category}</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: getStatusColor(k.status || 'good'), marginTop: 2 }}>{k.value}</div>
|
||||
{k.sublabel ? <div style={{ fontSize: 9, color: 'var(--text3)', marginTop: 2 }}>{k.sublabel}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sec.interpretation_short || []).map((it, i) => (
|
||||
<div key={`in-${i}`} style={{ fontSize: 11, marginBottom: 6, paddingLeft: 8, borderLeft: `3px solid ${getStatusColor(it.status || 'good')}` }}>
|
||||
<strong style={{ color: 'var(--text1)' }}>{it.title}</strong>
|
||||
<div style={{ color: 'var(--text2)', marginTop: 2, lineHeight: 1.4 }}>{it.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
{(sec.heuristic_short || []).map((h, i) => (
|
||||
<div key={`he-${i}`} style={{ fontSize: 11, marginTop: 6, padding: '6px 8px', borderRadius: 8, background: 'var(--surface2)' }}>
|
||||
<strong style={{ color: h.status === 'warn' ? 'var(--warn)' : 'var(--accent)' }}>{h.title}</strong>
|
||||
<div style={{ fontSize: 10, color: 'var(--text2)', marginTop: 2 }}>{h.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
{(sec.insights_short || []).map((ins, i) => (
|
||||
<div key={`is-${i}`} style={{ fontSize: 11, marginTop: 6, color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
<strong>{ins.title}</strong>
|
||||
<div>{ins.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{vis.show_correlation_c1_c3 && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text1)', margin: '18px 0 10px' }}>Lag-Korrelationen (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>
|
||||
)
|
||||
}
|
||||
608
frontend/src/components/history/NutritionHistoryVizSection.jsx
Normal file
608
frontend/src/components/history/NutritionHistoryVizSection.jsx
Normal file
|
|
@ -0,0 +1,608 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/de'
|
||||
import { api } from '../../utils/api'
|
||||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../../utils/macroChartTheme'
|
||||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../NutritionCharts'
|
||||
import KpiTilesOverview from '../KpiTilesOverview'
|
||||
import {
|
||||
NUTRITION_HISTORY_VIZ_HISTORY_FULL,
|
||||
filterNutritionHistoryKpiTiles,
|
||||
} from '../../widgetSystem/nutritionHistoryVizConfig'
|
||||
import { EmptySection, PeriodSelector, SectionHeader } from './historyPageChrome'
|
||||
|
||||
dayjs.locale('de')
|
||||
const fmtDate = (d) => dayjs(d).format('DD.MM')
|
||||
|
||||
function ChartFrame({ heightPx, children }) {
|
||||
return (
|
||||
<div style={{ width: '100%', minWidth: 0, height: heightPx }} className="nutrition-history-viz__chart-frame">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NutritionGoalsStrip({ grouped }) {
|
||||
const nav = useNavigate()
|
||||
const goals = (grouped?.nutrition || []).filter((g) => g.status === 'active').slice(0, 4)
|
||||
if (!goals.length) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
|
||||
Ziele <ChevronRight size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{goals.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
style={{
|
||||
flex: '1 1 140px',
|
||||
background: 'var(--surface2)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text2)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{g.name || g.label_de || g.goal_type}
|
||||
</div>
|
||||
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||||
height: '100%',
|
||||
background: 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}
|
||||
{g.unit ? ` ${g.unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function kcalVsWeightKcalDomain(points, tdeeRef) {
|
||||
const vals = (points || [])
|
||||
.map((d) => Number(d.kcal_avg))
|
||||
.filter((v) => !Number.isNaN(v))
|
||||
if (!vals.length) return ['auto', 'auto']
|
||||
let lo = Math.min(...vals)
|
||||
let hi = Math.max(...vals)
|
||||
const t = tdeeRef != null ? Number(tdeeRef) : NaN
|
||||
if (!Number.isNaN(t)) {
|
||||
lo = Math.min(lo, t)
|
||||
hi = Math.max(hi, t)
|
||||
}
|
||||
const span = hi - lo || 400
|
||||
const pad = Math.max(100, span * 0.1)
|
||||
return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)]
|
||||
}
|
||||
|
||||
const TDEE_REF_LINE_COLOR = '#475569'
|
||||
|
||||
function KcalVsWeightLegend({ showTdee }) {
|
||||
const line = (color) => ({
|
||||
display: 'inline-block',
|
||||
width: 22,
|
||||
height: 3,
|
||||
background: color,
|
||||
borderRadius: 1,
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 6,
|
||||
})
|
||||
return (
|
||||
<div
|
||||
className="kcal-vs-weight-legend"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '12px 18px',
|
||||
marginTop: 10,
|
||||
fontSize: 10,
|
||||
color: 'var(--text2)',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span style={line('#EA580C')} />
|
||||
Ø Kalorien (7-Tage-Mittel)
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: '50%',
|
||||
background: '#2563EB',
|
||||
marginRight: 6,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
/>
|
||||
Gewicht (kg)
|
||||
</span>
|
||||
{showTdee ? (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 22,
|
||||
height: 0,
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 6,
|
||||
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
|
||||
opacity: 0.95,
|
||||
}}
|
||||
/>
|
||||
TDEE-Referenz (geschätzt)
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics). */
|
||||
function KcalVsWeightChart({ vizKcalWeight, chartHeight = 200 }) {
|
||||
const n = vizKcalWeight?.points?.length ?? 0
|
||||
if (n < 5) {
|
||||
if (n === 0) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12, padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Kalorien (Ø 7 Tage) vs. Gewicht
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Für dieses Diagramm werden mindestens 5 Tage mit Kalorien- und Gewichtsdaten benötigt ({n} im Zeitraum).
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const tdee = vizKcalWeight.tdee_reference_kcal
|
||||
const kcalVsW = vizKcalWeight.points.map((d) => ({
|
||||
...d,
|
||||
date: fmtDate(d.date),
|
||||
}))
|
||||
const commonDays = vizKcalWeight.common_days_count ?? kcalVsW.length
|
||||
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
||||
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Kalorien (Ø 7 Tage) vs. Gewicht
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHeight}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
|
||||
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={kcalDomain} />
|
||||
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => [`${Math.round(v)} ${name === 'weight' ? 'kg' : 'kcal'}`, name === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
||||
/>
|
||||
{tdeeLabel != null && (
|
||||
<ReferenceLine
|
||||
yAxisId="kcal"
|
||||
y={tdeeLabel}
|
||||
stroke={TDEE_REF_LINE_COLOR}
|
||||
strokeDasharray="6 5"
|
||||
strokeWidth={2}
|
||||
isFront
|
||||
/>
|
||||
)}
|
||||
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
|
||||
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
||||
{tdeeLabel != null
|
||||
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
|
||||
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Verlauf → Ernährung: GET /charts/nutrition-history-viz (Layer 2b) + optional NutritionCharts mit Bundle-Payloads.
|
||||
* @param {object} [props.visibility] — Dashboard-Config; undefined = voller Verlauf
|
||||
* @param {number} [props.externalPeriod] — feste Tage (7–90) im Widget
|
||||
* @param {boolean} [props.embedded]
|
||||
* @param {import('react').ReactNode} [props.footer]
|
||||
*/
|
||||
export default function NutritionHistoryVizSection({ externalPeriod, embedded = false, visibility, footer = null }) {
|
||||
const display = visibility === undefined ? NUTRITION_HISTORY_VIZ_HISTORY_FULL : visibility
|
||||
const chartHMain = embedded ? 176 : 200
|
||||
const chartHBal = embedded ? 160 : 180
|
||||
const chartHPlm = embedded ? 160 : 180
|
||||
|
||||
const [internalPeriod, setInternalPeriod] = useState(30)
|
||||
const period = externalPeriod !== undefined ? externalPeriod : internalPeriod
|
||||
const showPeriodSelector = externalPeriod === undefined
|
||||
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
const [vizLoad, setVizLoad] = useState(true)
|
||||
const [vizErr, setVizErr] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!display.show_goals_strip) {
|
||||
setGroupedGoals({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
.then((g) => {
|
||||
if (!cancelled) setGroupedGoals(g)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGroupedGoals({})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [display.show_goals_strip])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setViz(null)
|
||||
setVizLoad(true)
|
||||
setVizErr(null)
|
||||
const daysReq = period === 9999 ? 9999 : period
|
||||
api.getNutritionHistoryViz(daysReq)
|
||||
.then((v) => {
|
||||
if (!cancelled) setViz(v)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setVizLoad(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
if (vizLoad) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
<div className="spinner" style={{ margin: 24 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (vizErr) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
|
||||
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viz?.has_nutrition_entries) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const summary = viz.summary || {}
|
||||
const n = Math.max(0, Number(summary.data_points) || 0)
|
||||
const avgKcal = Math.round(Number(summary.kcal_avg) || 0)
|
||||
const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0)
|
||||
const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period)
|
||||
const kpiTilesRaw = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel,
|
||||
}))
|
||||
const kpiTilesShown = display.show_kpis
|
||||
? filterNutritionHistoryKpiTiles(kpiTilesRaw, display.kpi_detail || 'full')
|
||||
: []
|
||||
const pieData = viz.donut_avg_pct || []
|
||||
const cdMacro = (viz.daily_macros || []).map((d) => ({
|
||||
date: fmtDate(d.date),
|
||||
Protein: d.Protein,
|
||||
KH: d.KH,
|
||||
Fett: d.Fett,
|
||||
kcal: d.kcal,
|
||||
}))
|
||||
const weeklyMacro = viz.weekly_macro_chart
|
||||
const balDaily = viz.calorie_balance_daily || []
|
||||
const plm = viz.protein_vs_lean_mass || {}
|
||||
const plmPts = plm.points || []
|
||||
const nutHeur = viz.nutrition_correlation_heuristics || []
|
||||
const tdeeRef = viz.tdee_reference_kcal
|
||||
|
||||
if (!cdMacro.length || n === 0) {
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
<EmptySection text="Keine Einträge im gewählten Zeitraum." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!embedded && <SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />}
|
||||
{embedded && viz?.last_updated && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 10 }}>
|
||||
Stand {dayjs(viz.last_updated).format('DD.MM.YY')}
|
||||
</div>
|
||||
)}
|
||||
{showPeriodSelector && <PeriodSelector value={period} onChange={setInternalPeriod} />}
|
||||
|
||||
{display.show_intro_blurb && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
|
||||
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
|
||||
{' '}
|
||||
<strong>Kalorienbilanz</strong>, <strong>Protein vs. Magermasse</strong> und den Block{' '}
|
||||
<strong>«Kurz-Einordnung»</strong> finden Sie hier — früher im eigenen Reiter «Korrelation» (jetzt Data-Layer-Bundle).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{display.show_goals_strip && <NutritionGoalsStrip grouped={groupedGoals} />}
|
||||
|
||||
{kpiTilesShown.length > 0 && <KpiTilesOverview tiles={kpiTilesShown} />}
|
||||
|
||||
{display.show_kcal_vs_weight && <KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} chartHeight={chartHMain} />}
|
||||
|
||||
{display.show_calorie_balance_chart && balDaily.length > 0 && tdeeRef != null && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Kalorienbilanz (Aufnahme − TDEE ~{Math.round(tdeeRef)} kcal)
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Tagesbilanz und 7-Tage-Mittel — gleiche TDEE-Quelle wie «Kalorien vs. Gewicht» (Data-Layer).
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHBal}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={balDaily.map((d) => ({ ...d, date: fmtDate(d.date) }))}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: -16 }}
|
||||
>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(balDaily.length / 6) - 1)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<ReferenceLine y={0} stroke="var(--text3)" strokeWidth={1.5} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => [`${v > 0 ? '+' : ''}${v} kcal`, name === 'balance_kcal_avg' ? 'Ø 7T Bilanz' : 'Tagesbilanz']}
|
||||
/>
|
||||
<Line type="monotone" dataKey="balance_kcal" stroke="#EF9F2744" strokeWidth={1} dot={false} name="balance_kcal" />
|
||||
<Line type="monotone" dataKey="balance_kcal_avg" stroke="#EF9F27" strokeWidth={2.5} dot={false} name="balance_kcal_avg" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_protein_lean_chart && plmPts.length >= 3 && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Protein vs. Magermasse (Caliper, forward-filled)
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Zweitachsig; gestrichelte Linie = Protein-Minimum ({plm.protein_target_low_g || ptLow || '—'} g), wenn verfügbar.
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHPlm}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={plmPts.map((d) => ({ ...d, date: fmtDate(d.date), protein: d.protein_g, lean: d.lean_mass_kg }))} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis yAxisId="prot" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<YAxis yAxisId="lean" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
{plm.protein_target_low_g > 0 && (
|
||||
<ReferenceLine
|
||||
yAxisId="prot"
|
||||
y={plm.protein_target_low_g}
|
||||
stroke="#1D9E75"
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1.5}
|
||||
label={{ value: `${plm.protein_target_low_g}g`, fontSize: 9, fill: '#1D9E75', position: 'right' }}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, name) => [`${v}${name === 'protein' ? 'g' : ' kg'}`, name === 'protein' ? 'Protein' : 'Mager']}
|
||||
/>
|
||||
<Line yAxisId="prot" type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} dot={false} name="protein" />
|
||||
<Line yAxisId="lean" type="monotone" dataKey="lean" stroke="#7F77DD" strokeWidth={2} dot={{ r: 3, fill: '#7F77DD' }} name="lean" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_heuristics && nutHeur.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Ernährung — Kurz-Einordnung</div>
|
||||
{nutHeur.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
marginBottom: 6,
|
||||
background: item.status === 'good' ? 'var(--accent-light)' : 'var(--warn-bg)',
|
||||
border: `1px solid ${item.status === 'good' ? 'var(--accent)' : 'var(--warn)'}33`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<span style={{ fontSize: 16 }}>{item.icon || '•'}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 3, lineHeight: 1.5 }}>{item.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_macro_daily_bars && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Makroverteilung täglich (g) · Fokus Protein
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
|
||||
</div>
|
||||
<ChartFrame heightPx={chartHMain}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
{ptLow > 0 && (
|
||||
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
|
||||
)}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
|
||||
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
|
||||
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
|
||||
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
|
||||
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
|
||||
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
|
||||
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_macro_distribution_pair && (
|
||||
<div className="nutrition-macro-pair">
|
||||
<div className="card nutrition-macro-pair__donut">
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||
Ø Makro-Quote ({n} Tage)
|
||||
</div>
|
||||
{pieData.length > 0 ? (
|
||||
<div className="nutrition-macro-pair__donut-inner">
|
||||
<div className="nutrition-macro-pair__donut-chart">
|
||||
<ChartFrame heightPx={NUTRITION_MACRO_CHART_BLOCK_PX}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius="38%"
|
||||
outerRadius="58%"
|
||||
dataKey="value"
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
paddingAngle={1}
|
||||
>
|
||||
{pieData.map((e, i) => (
|
||||
<Cell key={i} fill={macroFillByName(e.name)} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
</div>
|
||||
<div className="nutrition-macro-pair__legend">
|
||||
{pieData.map((p) => {
|
||||
const fill = macroFillByName(p.name)
|
||||
return (
|
||||
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
{p.grams != null ? `${p.grams}g` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
|
||||
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="card nutrition-macro-pair__weekly">
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
|
||||
Wöchentliche Makro-Verteilung (Backend)
|
||||
</div>
|
||||
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={false} error={null} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{display.show_energy_protein_charts && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
|
||||
Zeitverläufe (Energie & Protein)
|
||||
</div>
|
||||
<NutritionCharts
|
||||
days={chartDays}
|
||||
showWeeklyMacroDistribution={false}
|
||||
hideEnergyAvailabilityCard
|
||||
prefetchedChartPayloads={viz.chart_payloads}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import RecoveryDashboardOverview from '../RecoveryDashboardOverview'
|
||||
|
||||
/**
|
||||
* Verlauf → Erholung bzw. Dashboard-Widget: GET /charts/recovery-dashboard-viz (Layer 2b).
|
||||
*/
|
||||
export default function RecoveryHistoryVizSection({
|
||||
externalPeriod,
|
||||
period,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector,
|
||||
embedded,
|
||||
visibility,
|
||||
footer,
|
||||
}) {
|
||||
return (
|
||||
<RecoveryDashboardOverview
|
||||
period={period}
|
||||
onPeriodChange={onPeriodChange}
|
||||
hidePeriodSelector={hidePeriodSelector}
|
||||
externalPeriod={externalPeriod}
|
||||
embedded={embedded}
|
||||
visibility={visibility}
|
||||
footer={footer}
|
||||
/>
|
||||
)
|
||||
}
|
||||
87
frontend/src/components/history/historyPageChrome.jsx
Normal file
87
frontend/src/components/history/historyPageChrome.jsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export function NavToCaliper() {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/caliper')}>
|
||||
Caliper-Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavToCircum() {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/circum')}>
|
||||
Umfang-Daten <ChevronRight size={10} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptySection({ text, to, toLabel }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text3)', fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 12 }}>{text}</div>
|
||||
{to && (
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav(to)}>
|
||||
{toLabel || 'Daten erfassen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionHeader({ title, to, toLabel, lastUpdated }) {
|
||||
const nav = useNavigate()
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<h2 style={{ fontSize: 17, fontWeight: 700, margin: 0 }}>{title}</h2>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{lastUpdated && <span style={{ fontSize: 11, color: 'var(--text3)' }}>{dayjs(lastUpdated).format('DD.MM.YY')}</span>}
|
||||
{to && (
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 12, padding: '5px 10px' }} onClick={() => nav(to)}>
|
||||
{toLabel || 'Daten'} <ChevronRight size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PeriodSelector({ value, onChange }) {
|
||||
const opts = [
|
||||
{ v: 30, l: '30 Tage' },
|
||||
{ v: 90, l: '90 Tage' },
|
||||
{ v: 180, l: '6 Monate' },
|
||||
{ v: 365, l: '1 Jahr' },
|
||||
{ v: 9999, l: 'Alles' },
|
||||
]
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 12 }}>
|
||||
{opts.map((o) => (
|
||||
<button
|
||||
key={o.v}
|
||||
type="button"
|
||||
onClick={() => onChange(o.v)}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
border: '1.5px solid',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'var(--font)',
|
||||
background: value === o.v ? 'var(--accent)' : 'transparent',
|
||||
borderColor: value === o.v ? 'var(--accent)' : 'var(--border2)',
|
||||
color: value === o.v ? 'white' : 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,11 @@ import {
|
|||
} from '../widgetSystem/bodyChartDays'
|
||||
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import {
|
||||
moveWidget,
|
||||
moveWidgetToIndex,
|
||||
|
|
@ -20,8 +25,13 @@ import {
|
|||
|
||||
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||
'body_overview',
|
||||
'body_history_viz',
|
||||
'activity_overview',
|
||||
'nutrition_detail_charts',
|
||||
'nutrition_history_viz',
|
||||
'fitness_history_viz',
|
||||
'recovery_history_viz',
|
||||
'history_overview_viz',
|
||||
'recovery_charts_panel',
|
||||
])
|
||||
|
||||
|
|
@ -495,6 +505,91 @@ export default function DashboardConfigurePage({ adminMode = false } = {}) {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{w.id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'nutrition_history_viz' && (
|
||||
<NutritionHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'fitness_history_viz' && (
|
||||
<FitnessHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'recovery_history_viz' && (
|
||||
<RecoveryHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'history_overview_viz' && (
|
||||
<HistoryOverviewVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,23 @@ import {
|
|||
} from '../widgetSystem/bodyChartDays'
|
||||
import KpiBoardConfigEditor from '../widgetSystem/KpiBoardConfigEditor'
|
||||
import QuickCaptureConfigEditor from '../widgetSystem/QuickCaptureConfigEditor'
|
||||
import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor'
|
||||
import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor'
|
||||
import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor'
|
||||
import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor'
|
||||
import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor'
|
||||
import { moveWidget, normalizeLayoutForEditor, toggleWidget } from '../widgetSystem/layoutEditor'
|
||||
|
||||
/** Widgets mit optionalem config.chart_days (7–90), gleiche UX im Editor */
|
||||
const CHART_DAYS_WIDGET_IDS = new Set([
|
||||
'body_overview',
|
||||
'body_history_viz',
|
||||
'activity_overview',
|
||||
'nutrition_detail_charts',
|
||||
'nutrition_history_viz',
|
||||
'fitness_history_viz',
|
||||
'recovery_history_viz',
|
||||
'history_overview_viz',
|
||||
'recovery_charts_panel',
|
||||
])
|
||||
|
||||
|
|
@ -318,10 +328,20 @@ export default function DashboardLabPage() {
|
|||
<label style={{ fontSize: 12, color: 'var(--text2)', display: 'block', marginBottom: 4 }}>
|
||||
{w.id === 'body_overview'
|
||||
? 'Körper-Chart'
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper (Verlauf-Bundle)'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität (Verteilung & Konsistenz)'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährung — Charts'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung (Verlauf-Bundle)'
|
||||
: w.id === 'fitness_history_viz'
|
||||
? 'Fitness (Verlauf-Bundle)'
|
||||
: w.id === 'history_overview_viz'
|
||||
? 'Gesamtübersicht (Verlauf-Bundle)'
|
||||
: w.id === 'recovery_history_viz'
|
||||
? 'Erholung (Verlauf-Bundle)'
|
||||
: 'Erholung — Charts'}{' '}
|
||||
— Zeitraum (Tage): {BODY_CHART_DAYS_MIN}–{BODY_CHART_DAYS_MAX}
|
||||
</label>
|
||||
|
|
@ -334,10 +354,20 @@ export default function DashboardLabPage() {
|
|||
aria-label={
|
||||
w.id === 'body_overview'
|
||||
? 'Körper-Chart Zeitraum in Tagen'
|
||||
: w.id === 'body_history_viz'
|
||||
? 'Körper Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'activity_overview'
|
||||
? 'Aktivität Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_detail_charts'
|
||||
? 'Ernährungs-Charts Zeitraum in Tagen'
|
||||
: w.id === 'nutrition_history_viz'
|
||||
? 'Ernährung Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'fitness_history_viz'
|
||||
? 'Fitness Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'history_overview_viz'
|
||||
? 'Gesamtübersicht Verlauf-Bundle Zeitraum in Tagen'
|
||||
: w.id === 'recovery_history_viz'
|
||||
? 'Erholung Verlauf-Bundle Zeitraum in Tagen'
|
||||
: 'Erholungs-Charts Zeitraum in Tagen'
|
||||
}
|
||||
value={
|
||||
|
|
@ -374,6 +404,91 @@ export default function DashboardLabPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{w.id === 'body_history_viz' && (
|
||||
<BodyHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'nutrition_history_viz' && (
|
||||
<NutritionHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'fitness_history_viz' && (
|
||||
<FitnessHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'recovery_history_viz' && (
|
||||
<RecoveryHistoryVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{w.id === 'history_overview_viz' && (
|
||||
<HistoryOverviewVizConfigEditor
|
||||
config={w.config || {}}
|
||||
onChange={(next) =>
|
||||
setLayout((L) =>
|
||||
normalizeLayoutForEditor({
|
||||
...L,
|
||||
widgets: L.widgets.map((x, j) => {
|
||||
if (j !== i) return x
|
||||
if (Object.keys(next).length === 0) return { ...x, config: {} }
|
||||
return { ...x, config: { ...(x.config || {}), ...next } }
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
91
frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx
Normal file
91
frontend/src/widgetSystem/BodyHistoryVizConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { BODY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
||||
|
||||
const CHART_TOGGLES = [
|
||||
{ key: 'show_weight_chart', label: 'Gewichts-Chart' },
|
||||
{ key: 'show_body_fat_chart', label: 'Körperfett (Caliper)' },
|
||||
{ key: 'show_proportion_chart', label: 'Silhouette & Proportion' },
|
||||
{ key: 'show_circumference_index_chart', label: 'Umfänge — Index' },
|
||||
{ key: 'show_circumference_lines_chart', label: 'Umfänge — Linien (Fallback)' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_goals_strip', label: 'Körper-Ziele (Strip)' },
|
||||
{ key: 'show_intro_blurb', label: 'Hinweistext unter Zielen' },
|
||||
{ key: 'show_layer_meta', label: 'Layer-2a-Hinweis (Meta)' },
|
||||
{ key: 'show_kpis', label: 'KPI-Kacheln' },
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
|
||||
*/
|
||||
export default function BodyHistoryVizConfigEditor({ config, onChange }) {
|
||||
const merged = normalizeBodyHistoryVizConfig(config)
|
||||
|
||||
const patch = (partial) => {
|
||||
const next = { ...merged, ...partial }
|
||||
const def = BODY_HISTORY_VIZ_WIDGET_DEFAULTS
|
||||
const stored = {}
|
||||
for (const k of Object.keys(def)) {
|
||||
if (next[k] !== def[k]) stored[k] = next[k]
|
||||
}
|
||||
onChange(stored)
|
||||
}
|
||||
|
||||
const setBool = (key, checked) => {
|
||||
patch({ [key]: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||
<strong>Körper (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
|
||||
Standard (nur KPI kompakt + Gewicht).
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="body_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'compact'}
|
||||
onChange={() => patch({ kpi_detail: 'compact' })}
|
||||
/>
|
||||
<span>Kompakt (Gewicht, KF%, Magermasse)</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="body_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'full'}
|
||||
onChange={() => patch({ kpi_detail: 'full' })}
|
||||
/>
|
||||
<span>Voll (wie Verlauf — alle Kacheln)</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{CHART_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Auf schlanken Standard zurück
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx
Normal file
89
frontend/src/widgetSystem/FitnessHistoryVizConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
|
||||
|
||||
const CHART_TOGGLES = [
|
||||
{ key: 'show_chart_training_volume', label: 'Trainingsvolumen (Balken)' },
|
||||
{ key: 'show_chart_training_type_distribution', label: 'Training nach Kategorie (Kuchen)' },
|
||||
{ key: 'show_chart_quality_sessions', label: 'Qualitäts-Sessions' },
|
||||
{ key: 'show_chart_load_monitoring', label: 'Belastung / Load-Zeitreihe' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Issue-53-Hinweis)' },
|
||||
{ key: 'show_kpis', label: 'KPI-Kacheln' },
|
||||
{ key: 'show_progress_insights', label: 'Einschätzungen (Progress-Insights)' },
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
|
||||
*/
|
||||
export default function FitnessHistoryVizConfigEditor({ config, onChange }) {
|
||||
const merged = normalizeFitnessHistoryVizConfig(config)
|
||||
|
||||
const patch = (partial) => {
|
||||
const next = { ...merged, ...partial }
|
||||
const def = FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS
|
||||
const stored = {}
|
||||
for (const k of Object.keys(def)) {
|
||||
if (next[k] !== def[k]) stored[k] = next[k]
|
||||
}
|
||||
onChange(stored)
|
||||
}
|
||||
|
||||
const setBool = (key, checked) => {
|
||||
patch({ [key]: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||
<strong>Fitness (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
|
||||
Standard (KPI kompakt, Volumen + Kategorien).
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="fitness_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'compact'}
|
||||
onChange={() => patch({ kpi_detail: 'compact' })}
|
||||
/>
|
||||
<span>Kompakt (erste 4 Kacheln)</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="fitness_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'full'}
|
||||
onChange={() => patch({ kpi_detail: 'full' })}
|
||||
/>
|
||||
<span>Voll (alle Kacheln)</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{CHART_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Auf schlanken Standard zurück
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx
Normal file
65
frontend/src/widgetSystem/HistoryOverviewVizConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS,
|
||||
normalizeHistoryOverviewVizConfig,
|
||||
} from './historyOverviewVizConfig'
|
||||
|
||||
const SECTION_TOGGLES = [
|
||||
{ key: 'show_section_body', label: 'Körper' },
|
||||
{ key: 'show_section_nutrition', label: 'Ernährung' },
|
||||
{ key: 'show_section_fitness', label: 'Fitness' },
|
||||
{ key: 'show_section_recovery', label: 'Erholung' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_confidence_banner', label: 'Banner «Datenlage»' },
|
||||
{ key: 'show_intro_blurb', label: 'Hinweistext (Ernährung / API)' },
|
||||
{ key: 'show_correlation_c1_c3', label: 'Lag-Korrelationen 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> welche Bereichs-Kacheln und weitere Blöcke erscheinen.
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereichs-Kacheln</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
||||
{SECTION_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Weitere Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
|
||||
|
||||
const CHART_TOGGLES = [
|
||||
{ key: 'show_kcal_vs_weight', label: 'Kalorien vs. Gewicht' },
|
||||
{ key: 'show_calorie_balance_chart', label: 'Kalorienbilanz' },
|
||||
{ key: 'show_protein_lean_chart', label: 'Protein vs. Magermasse' },
|
||||
{ key: 'show_heuristics', label: 'Kurz-Einordnung (Heuristiken)' },
|
||||
{ key: 'show_macro_daily_bars', label: 'Makros täglich (Balken)' },
|
||||
{ key: 'show_macro_distribution_pair', label: 'Donut + Wochen-Makros' },
|
||||
{ key: 'show_energy_protein_charts', label: 'Zeitverläufe (NutritionCharts, Bundle-Payloads)' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_goals_strip', label: 'Ernährungs-Ziele (Strip)' },
|
||||
{ key: 'show_intro_blurb', label: 'Hinweistext (Data-Layer)' },
|
||||
{ key: 'show_kpis', label: 'KPI-Kacheln' },
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
|
||||
*/
|
||||
export default function NutritionHistoryVizConfigEditor({ config, onChange }) {
|
||||
const merged = normalizeNutritionHistoryVizConfig(config)
|
||||
|
||||
const patch = (partial) => {
|
||||
const next = { ...merged, ...partial }
|
||||
const def = NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS
|
||||
const stored = {}
|
||||
for (const k of Object.keys(def)) {
|
||||
if (next[k] !== def[k]) stored[k] = next[k]
|
||||
}
|
||||
onChange(stored)
|
||||
}
|
||||
|
||||
const setBool = (key, checked) => {
|
||||
patch({ [key]: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||
<strong>Ernährung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
|
||||
Standard (KPI kompakt, kcal vs. Gewicht, Makro-Balken + Donut/Woche).
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="nutrition_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'compact'}
|
||||
onChange={() => patch({ kpi_detail: 'compact' })}
|
||||
/>
|
||||
<span>Kompakt (erste 4 Kacheln)</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="nutrition_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'full'}
|
||||
onChange={() => patch({ kpi_detail: 'full' })}
|
||||
/>
|
||||
<span>Voll (wie Verlauf — alle Kacheln)</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{CHART_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Auf schlanken Standard zurück
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx
Normal file
106
frontend/src/widgetSystem/RecoveryHistoryVizConfigEditor.jsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS, normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
|
||||
|
||||
const CHART_TOGGLES = [
|
||||
{ key: 'show_chart_recovery_score', label: 'HRV-/Recovery-Score-Verlauf' },
|
||||
{ key: 'show_chart_sleep_quality', label: 'Schlaf: Dauer & Qualität' },
|
||||
{ key: 'show_chart_sleep_debt', label: 'Schlafschuld' },
|
||||
{ key: 'show_chart_hrv_rhr', label: 'HRV & Ruhepuls (Zeitverlauf)' },
|
||||
]
|
||||
|
||||
const SECTION_TOGGLES = [
|
||||
{ key: 'show_sleep_section_heading', label: 'Zwischenüberschrift «Schlaf & Erholung»' },
|
||||
{ key: 'show_heart_section_heading', label: 'Zwischenüberschrift «Herz & Kreislauf»' },
|
||||
{ key: 'show_heart_context_card', label: 'Herz: Einordnung, Zonen, Snapshots' },
|
||||
{ key: 'show_vitals_extra_heading', label: 'Überschrift «Weitere Vitalparameter»' },
|
||||
{ key: 'show_vitals_extra_trends', label: 'VO2 / SpO2 / Atemfrequenz (Verläufe)' },
|
||||
]
|
||||
|
||||
const OTHER_TOGGLES = [
|
||||
{ key: 'show_layer_meta', label: 'Meta-Zeile (Fenster-Tage, Data-Layer)' },
|
||||
{ key: 'show_kpis', label: 'KPI-Kacheln' },
|
||||
{ key: 'show_progress_insights', label: 'Überblick: Recovery & Schlaf (Karten)' },
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {{ config: Record<string, unknown>, onChange: (next: Record<string, unknown>) => void }} props
|
||||
*/
|
||||
export default function RecoveryHistoryVizConfigEditor({ config, onChange }) {
|
||||
const merged = normalizeRecoveryHistoryVizConfig(config)
|
||||
|
||||
const patch = (partial) => {
|
||||
const next = { ...merged, ...partial }
|
||||
const def = RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS
|
||||
const stored = {}
|
||||
for (const k of Object.keys(def)) {
|
||||
if (next[k] !== def[k]) stored[k] = next[k]
|
||||
}
|
||||
onChange(stored)
|
||||
}
|
||||
|
||||
const setBool = (key, checked) => {
|
||||
patch({ [key]: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 10, marginLeft: 28 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 8, lineHeight: 1.5 }}>
|
||||
<strong>Erholung (Verlauf-Bundle):</strong> welche Blöcke auf der Übersicht erscheinen. Unbelegte Felder = schlanker
|
||||
Standard (KPI kompakt, Schlaf-Charts, HRV/RHR — ohne Kontextkarte und Extra-Vitals).
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>KPI-Umfang</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 10, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="recovery_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'compact'}
|
||||
onChange={() => patch({ kpi_detail: 'compact' })}
|
||||
/>
|
||||
<span>Kompakt (erste 4 Kacheln)</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, marginBottom: 12, cursor: 'pointer' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="recovery_hist_kpi_detail"
|
||||
checked={merged.kpi_detail === 'full'}
|
||||
onChange={() => patch({ kpi_detail: 'full' })}
|
||||
/>
|
||||
<span>Voll (alle Kacheln)</span>
|
||||
</label>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', marginBottom: 6 }}>Bereiche</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{OTHER_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Abschnitte</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{SECTION_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', margin: '12px 0 6px' }}>Charts</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{CHART_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={merged[key]} onChange={(e) => setBool(key, e.target.checked)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, fontSize: 12, padding: '6px 12px' }}
|
||||
onClick={() => onChange({})}
|
||||
>
|
||||
Auf schlanken Standard zurück
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
frontend/src/widgetSystem/bodyHistoryVizConfig.js
Normal file
81
frontend/src/widgetSystem/bodyHistoryVizConfig.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Sichtbarkeit / Umfang für body_history_viz (sync mit backend dashboard_widget_config).
|
||||
* `null` / fehlend → Verlauf: alles sichtbar (vollständige Parität zur History-Seite).
|
||||
*/
|
||||
|
||||
/** Verlauf-Tab Körper: volle Parität (kein Layout-Config). */
|
||||
export const BODY_HISTORY_VIZ_HISTORY_FULL = {
|
||||
chart_days: 90,
|
||||
show_goals_strip: true,
|
||||
show_intro_blurb: true,
|
||||
show_layer_meta: true,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'full',
|
||||
show_weight_chart: true,
|
||||
show_body_fat_chart: true,
|
||||
show_proportion_chart: true,
|
||||
show_circumference_index_chart: true,
|
||||
show_circumference_lines_chart: true,
|
||||
}
|
||||
|
||||
/** Default für Dashboard-Widget (schlank). */
|
||||
export const BODY_HISTORY_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_goals_strip: false,
|
||||
show_intro_blurb: false,
|
||||
show_layer_meta: false,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'compact',
|
||||
show_weight_chart: true,
|
||||
show_body_fat_chart: false,
|
||||
show_proportion_chart: false,
|
||||
show_circumference_index_chart: false,
|
||||
show_circumference_lines_chart: false,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_goals_strip',
|
||||
'show_intro_blurb',
|
||||
'show_layer_meta',
|
||||
'show_kpis',
|
||||
'show_weight_chart',
|
||||
'show_body_fat_chart',
|
||||
'show_proportion_chart',
|
||||
'show_circumference_index_chart',
|
||||
'show_circumference_lines_chart',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw — aus Layout-Config (Backend liefert nach Save ggf. alle Keys)
|
||||
* @returns {typeof BODY_HISTORY_VIZ_WIDGET_DEFAULTS}
|
||||
*/
|
||||
export function normalizeBodyHistoryVizConfig(raw) {
|
||||
const base = { ...BODY_HISTORY_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
|
||||
base.kpi_detail = raw.kpi_detail
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const COMPACT_KPI_KEYS = new Set(['weight', 'bf', 'lean_ffmi'])
|
||||
|
||||
/**
|
||||
* @param {Array<{ key: string }>} tiles
|
||||
* @param {'compact'|'full'} detail
|
||||
*/
|
||||
export function filterBodyHistoryKpiTiles(tiles, detail) {
|
||||
if (detail === 'full' || !Array.isArray(tiles)) return tiles
|
||||
return tiles.filter((t) => COMPACT_KPI_KEYS.has(t.key))
|
||||
}
|
||||
70
frontend/src/widgetSystem/fitnessHistoryVizConfig.js
Normal file
70
frontend/src/widgetSystem/fitnessHistoryVizConfig.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Sichtbarkeit für fitness_history_viz (sync mit backend dashboard_widget_config).
|
||||
* `visibility === undefined` → Verlauf: alles an (wie bisherige Fitness-Übersicht).
|
||||
*/
|
||||
|
||||
export const FITNESS_HISTORY_VIZ_HISTORY_FULL = {
|
||||
chart_days: 30,
|
||||
show_layer_meta: true,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'full',
|
||||
show_progress_insights: true,
|
||||
show_chart_training_volume: true,
|
||||
show_chart_training_type_distribution: true,
|
||||
show_chart_quality_sessions: true,
|
||||
show_chart_load_monitoring: true,
|
||||
}
|
||||
|
||||
export const FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_layer_meta: false,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'compact',
|
||||
show_progress_insights: false,
|
||||
show_chart_training_volume: true,
|
||||
show_chart_training_type_distribution: true,
|
||||
show_chart_quality_sessions: false,
|
||||
show_chart_load_monitoring: false,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_layer_meta',
|
||||
'show_kpis',
|
||||
'show_progress_insights',
|
||||
'show_chart_training_volume',
|
||||
'show_chart_training_type_distribution',
|
||||
'show_chart_quality_sessions',
|
||||
'show_chart_load_monitoring',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw
|
||||
*/
|
||||
export function normalizeFitnessHistoryVizConfig(raw) {
|
||||
const base = { ...FITNESS_HISTORY_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
|
||||
base.kpi_detail = raw.kpi_detail
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<object>} kpiTiles
|
||||
* @param {'compact'|'full'} detail
|
||||
*/
|
||||
export function filterFitnessHistoryKpiTiles(kpiTiles, detail) {
|
||||
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
|
||||
return kpiTiles.slice(0, 4)
|
||||
}
|
||||
75
frontend/src/widgetSystem/historyOverviewVizConfig.js
Normal file
75
frontend/src/widgetSystem/historyOverviewVizConfig.js
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* Sichtbarkeit für history_overview_viz (sync mit backend dashboard_widget_config).
|
||||
* `visibility === undefined` → Verlauf-Tab: volle Gesamtübersicht (wie bisher).
|
||||
*/
|
||||
|
||||
export const HISTORY_OVERVIEW_VIZ_SECTION_KEYS = [
|
||||
'show_section_body',
|
||||
'show_section_nutrition',
|
||||
'show_section_fitness',
|
||||
'show_section_recovery',
|
||||
]
|
||||
|
||||
export const HISTORY_OVERVIEW_VIZ_PAGE_FULL = {
|
||||
chart_days: 30,
|
||||
show_confidence_banner: true,
|
||||
show_intro_blurb: true,
|
||||
show_section_body: true,
|
||||
show_section_nutrition: true,
|
||||
show_section_fitness: true,
|
||||
show_section_recovery: true,
|
||||
show_correlation_c1_c3: true,
|
||||
show_drivers_c4: true,
|
||||
}
|
||||
|
||||
export const HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_confidence_banner: true,
|
||||
show_intro_blurb: true,
|
||||
show_section_body: true,
|
||||
show_section_nutrition: true,
|
||||
show_section_fitness: true,
|
||||
show_section_recovery: true,
|
||||
show_correlation_c1_c3: true,
|
||||
show_drivers_c4: true,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_confidence_banner',
|
||||
'show_intro_blurb',
|
||||
...HISTORY_OVERVIEW_VIZ_SECTION_KEYS,
|
||||
'show_correlation_c1_c3',
|
||||
'show_drivers_c4',
|
||||
]
|
||||
|
||||
function hasExplicitSectionKeys(raw) {
|
||||
return HISTORY_OVERVIEW_VIZ_SECTION_KEYS.some((k) => Object.prototype.hasOwnProperty.call(raw, k))
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw
|
||||
*/
|
||||
export function normalizeHistoryOverviewVizConfig(raw) {
|
||||
const base = { ...HISTORY_OVERVIEW_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'show_area_summaries') && !hasExplicitSectionKeys(raw)) {
|
||||
const v = raw.show_area_summaries === true
|
||||
for (const k of HISTORY_OVERVIEW_VIZ_SECTION_KEYS) {
|
||||
base[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
79
frontend/src/widgetSystem/nutritionHistoryVizConfig.js
Normal file
79
frontend/src/widgetSystem/nutritionHistoryVizConfig.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Sichtbarkeit für nutrition_history_viz (sync mit backend dashboard_widget_config).
|
||||
* `visibility === undefined` → Verlauf-Tab: alles an.
|
||||
*/
|
||||
|
||||
export const NUTRITION_HISTORY_VIZ_HISTORY_FULL = {
|
||||
chart_days: 30,
|
||||
show_goals_strip: true,
|
||||
show_intro_blurb: true,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'full',
|
||||
show_kcal_vs_weight: true,
|
||||
show_calorie_balance_chart: true,
|
||||
show_protein_lean_chart: true,
|
||||
show_heuristics: true,
|
||||
show_macro_daily_bars: true,
|
||||
show_macro_distribution_pair: true,
|
||||
show_energy_protein_charts: true,
|
||||
}
|
||||
|
||||
export const NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_goals_strip: false,
|
||||
show_intro_blurb: false,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'compact',
|
||||
show_kcal_vs_weight: true,
|
||||
show_calorie_balance_chart: false,
|
||||
show_protein_lean_chart: false,
|
||||
show_heuristics: false,
|
||||
show_macro_daily_bars: true,
|
||||
show_macro_distribution_pair: true,
|
||||
show_energy_protein_charts: false,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_goals_strip',
|
||||
'show_intro_blurb',
|
||||
'show_kpis',
|
||||
'show_kcal_vs_weight',
|
||||
'show_calorie_balance_chart',
|
||||
'show_protein_lean_chart',
|
||||
'show_heuristics',
|
||||
'show_macro_daily_bars',
|
||||
'show_macro_distribution_pair',
|
||||
'show_energy_protein_charts',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw
|
||||
*/
|
||||
export function normalizeNutritionHistoryVizConfig(raw) {
|
||||
const base = { ...NUTRITION_HISTORY_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
|
||||
base.kpi_detail = raw.kpi_detail
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<object>} kpiTiles
|
||||
* @param {'compact'|'full'} detail
|
||||
*/
|
||||
export function filterNutritionHistoryKpiTiles(kpiTiles, detail) {
|
||||
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
|
||||
return kpiTiles.slice(0, 4)
|
||||
}
|
||||
85
frontend/src/widgetSystem/recoveryHistoryVizConfig.js
Normal file
85
frontend/src/widgetSystem/recoveryHistoryVizConfig.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* Sichtbarkeit für recovery_history_viz (sync mit backend dashboard_widget_config).
|
||||
* `visibility === undefined` → Verlauf: volle Erholungs-Übersicht (wie bisher).
|
||||
*/
|
||||
|
||||
export const RECOVERY_HISTORY_VIZ_HISTORY_FULL = {
|
||||
chart_days: 30,
|
||||
show_layer_meta: true,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'full',
|
||||
show_progress_insights: true,
|
||||
show_sleep_section_heading: true,
|
||||
show_chart_recovery_score: true,
|
||||
show_chart_sleep_quality: true,
|
||||
show_chart_sleep_debt: true,
|
||||
show_heart_section_heading: true,
|
||||
show_heart_context_card: true,
|
||||
show_chart_hrv_rhr: true,
|
||||
show_vitals_extra_heading: true,
|
||||
show_vitals_extra_trends: true,
|
||||
}
|
||||
|
||||
export const RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS = {
|
||||
chart_days: 30,
|
||||
show_layer_meta: false,
|
||||
show_kpis: true,
|
||||
kpi_detail: 'compact',
|
||||
show_progress_insights: false,
|
||||
show_sleep_section_heading: true,
|
||||
show_chart_recovery_score: true,
|
||||
show_chart_sleep_quality: true,
|
||||
show_chart_sleep_debt: false,
|
||||
show_heart_section_heading: true,
|
||||
show_heart_context_card: false,
|
||||
show_chart_hrv_rhr: true,
|
||||
show_vitals_extra_heading: false,
|
||||
show_vitals_extra_trends: false,
|
||||
}
|
||||
|
||||
const BOOL_KEYS = [
|
||||
'show_layer_meta',
|
||||
'show_kpis',
|
||||
'show_progress_insights',
|
||||
'show_sleep_section_heading',
|
||||
'show_chart_recovery_score',
|
||||
'show_chart_sleep_quality',
|
||||
'show_chart_sleep_debt',
|
||||
'show_heart_section_heading',
|
||||
'show_heart_context_card',
|
||||
'show_chart_hrv_rhr',
|
||||
'show_vitals_extra_heading',
|
||||
'show_vitals_extra_trends',
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} raw
|
||||
*/
|
||||
export function normalizeRecoveryHistoryVizConfig(raw) {
|
||||
const base = { ...RECOVERY_HISTORY_VIZ_WIDGET_DEFAULTS }
|
||||
if (!raw || typeof raw !== 'object') return base
|
||||
for (const k of BOOL_KEYS) {
|
||||
if (Object.prototype.hasOwnProperty.call(raw, k)) {
|
||||
base[k] = raw[k] === true
|
||||
}
|
||||
}
|
||||
if (raw.kpi_detail === 'full' || raw.kpi_detail === 'compact') {
|
||||
base.kpi_detail = raw.kpi_detail
|
||||
}
|
||||
if (raw.chart_days != null) {
|
||||
const n = Number(raw.chart_days)
|
||||
if (Number.isFinite(n)) {
|
||||
base.chart_days = Math.min(90, Math.max(7, Math.round(n)))
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<object>} kpiTiles
|
||||
* @param {'compact'|'full'} detail
|
||||
*/
|
||||
export function filterRecoveryHistoryKpiTiles(kpiTiles, detail) {
|
||||
if (detail === 'full' || !Array.isArray(kpiTiles)) return kpiTiles
|
||||
return kpiTiles.slice(0, 4)
|
||||
}
|
||||
|
|
@ -14,6 +14,16 @@ import ProfileGoalsProgressWidget from '../components/dashboard-widgets/ProfileG
|
|||
import TrendKcalWeightWidget from '../components/dashboard-widgets/TrendKcalWeightWidget'
|
||||
import NutritionActivitySummaryWidget from '../components/dashboard-widgets/NutritionActivitySummaryWidget'
|
||||
import NutritionDetailChartsWidget from '../components/dashboard-widgets/NutritionDetailChartsWidget'
|
||||
import BodyHistoryVizWidget from '../components/dashboard-widgets/BodyHistoryVizWidget'
|
||||
import NutritionHistoryVizWidget from '../components/dashboard-widgets/NutritionHistoryVizWidget'
|
||||
import FitnessHistoryVizWidget from '../components/dashboard-widgets/FitnessHistoryVizWidget'
|
||||
import RecoveryHistoryVizWidget from '../components/dashboard-widgets/RecoveryHistoryVizWidget'
|
||||
import HistoryOverviewVizWidget from '../components/dashboard-widgets/HistoryOverviewVizWidget'
|
||||
import { normalizeBodyHistoryVizConfig } from './bodyHistoryVizConfig'
|
||||
import { normalizeNutritionHistoryVizConfig } from './nutritionHistoryVizConfig'
|
||||
import { normalizeFitnessHistoryVizConfig } from './fitnessHistoryVizConfig'
|
||||
import { normalizeRecoveryHistoryVizConfig } from './recoveryHistoryVizConfig'
|
||||
import { normalizeHistoryOverviewVizConfig } from './historyOverviewVizConfig'
|
||||
import RecoveryChartsPanelWidget from '../components/dashboard-widgets/RecoveryChartsPanelWidget'
|
||||
import ProgressPhotosWidget from '../components/dashboard-widgets/ProgressPhotosWidget'
|
||||
import RecoverySleepRestWidget from '../components/dashboard-widgets/RecoverySleepRestWidget'
|
||||
|
|
@ -57,6 +67,14 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
chartDays: normalizeBodyChartDays(ctx.layoutEntry?.config?.chart_days),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'body_history_viz',
|
||||
Component: BodyHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
bodyHistoryVizConfig: normalizeBodyHistoryVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'activity_overview',
|
||||
Component: PilotActivitySection,
|
||||
|
|
@ -112,6 +130,38 @@ export function ensurePilotLabWidgetsRegistered() {
|
|||
chartDays: ctx.layoutEntry?.config?.chart_days,
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'nutrition_history_viz',
|
||||
Component: NutritionHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
nutritionHistoryVizConfig: normalizeNutritionHistoryVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'fitness_history_viz',
|
||||
Component: FitnessHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
fitnessHistoryVizConfig: normalizeFitnessHistoryVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'recovery_history_viz',
|
||||
Component: RecoveryHistoryVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
recoveryHistoryVizConfig: normalizeRecoveryHistoryVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'history_overview_viz',
|
||||
Component: HistoryOverviewVizWidget,
|
||||
mapProps: (ctx) => ({
|
||||
refreshTick: ctx.refreshTick,
|
||||
historyOverviewVizConfig: normalizeHistoryOverviewVizConfig(ctx.layoutEntry?.config),
|
||||
}),
|
||||
})
|
||||
registerDashboardWidget({
|
||||
id: 'recovery_charts_panel',
|
||||
Component: RecoveryChartsPanelWidget,
|
||||
|
|
|
|||
BIN
shinkan-dev-screenshot.png
Normal file
BIN
shinkan-dev-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
40
test-shinkan.js
Normal file
40
test-shinkan.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const { chromium } = require('playwright');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
console.log('=== Testing Shinkan Frontend ===\n');
|
||||
|
||||
try {
|
||||
await page.goto('http://192.168.2.49:3098', { waitUntil: 'networkidle', timeout: 10000 });
|
||||
|
||||
const title = await page.title();
|
||||
console.log('📄 Title:', title);
|
||||
|
||||
const h1 = await page.textContent('h1').catch(() => null);
|
||||
console.log('🥋 Heading:', h1);
|
||||
|
||||
const bodyText = await page.evaluate(() => document.body.innerText);
|
||||
console.log('\n📝 Page Content:\n' + '='.repeat(60));
|
||||
console.log(bodyText);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
const buttons = await page.locator('button').count();
|
||||
const forms = await page.locator('form').count();
|
||||
const inputs = await page.locator('input').count();
|
||||
|
||||
console.log('\n🔍 Elements Found:');
|
||||
console.log(' - Buttons:', buttons);
|
||||
console.log(' - Forms:', forms);
|
||||
console.log(' - Inputs:', inputs);
|
||||
|
||||
await page.screenshot({ path: 'shinkan-dev-screenshot.png', fullPage: true });
|
||||
console.log('\n📸 Screenshot: shinkan-dev-screenshot.png');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
Loading…
Reference in New Issue
Block a user