Refaktor Level 2b #100
|
|
@ -48,6 +48,31 @@ def _iso(d: Any) -> Optional[str]:
|
|||
return str(d)[:10]
|
||||
|
||||
|
||||
def _weight_trend_kpi(trend_periods: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
"""
|
||||
Kurzurteil Gewichtstrend (Schwelle ±0,25 kg, Priorität 90T → 30T → erste Periode).
|
||||
Eine Quelle mit dem Verlauf-Bundle — kein paralleles Frontend-Routing mehr.
|
||||
"""
|
||||
if not trend_periods:
|
||||
return {"verdict": "Stabil", "status": "good"}
|
||||
t90 = next((t for t in trend_periods if t.get("label") == "90T"), None)
|
||||
t30 = next((t for t in trend_periods if t.get("label") == "30T"), None)
|
||||
d: Optional[float] = None
|
||||
if t90 is not None and t90.get("diff_kg") is not None:
|
||||
d = float(t90["diff_kg"])
|
||||
elif t30 is not None and t30.get("diff_kg") is not None:
|
||||
d = float(t30["diff_kg"])
|
||||
elif trend_periods[0].get("diff_kg") is not None:
|
||||
d = float(trend_periods[0]["diff_kg"])
|
||||
else:
|
||||
return {"verdict": "Stabil", "status": "good"}
|
||||
if d < -0.25:
|
||||
return {"verdict": "Trend ↓", "status": "good"}
|
||||
if d > 0.25:
|
||||
return {"verdict": "Trend ↑", "status": "warn"}
|
||||
return {"verdict": "Stabil", "status": "good"}
|
||||
|
||||
|
||||
def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Returns chart-ready series and interpretation tiles for the body history tab.
|
||||
|
|
@ -437,6 +462,7 @@ def get_body_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
|||
"min_kg": min_w,
|
||||
"max_kg": max_w,
|
||||
"trend_periods": trend_periods,
|
||||
"trend_kpi": _weight_trend_kpi(trend_periods),
|
||||
"data_points": len(w_points),
|
||||
"related_placeholder_keys": [
|
||||
"weight_aktuell",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,15 @@ def _last_activity_date(profile_id: str) -> Optional[str]:
|
|||
return _iso(row["d"])
|
||||
|
||||
|
||||
def get_activity_last_updated_iso(profile_id: str) -> Optional[str]:
|
||||
"""
|
||||
Leichtgewicht: letztes activity_log.date — identisch zu ``last_updated`` im Fitness-Viz-Bundle.
|
||||
|
||||
Für History-Header o. Ä. ohne vollständige Aktivitätsliste (Phase A, Issue-53-Pfad).
|
||||
"""
|
||||
return _last_activity_date(profile_id)
|
||||
|
||||
|
||||
def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Bundle für Fitness-Übersicht: KPI-Kacheln + eingebettete Chart-Payloads (Chart.js-Format).
|
||||
|
|
|
|||
404
backend/data_layer/nutrition_chart_payloads.py
Normal file
404
backend/data_layer/nutrition_chart_payloads.py
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
"""
|
||||
Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4).
|
||||
|
||||
Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz``
|
||||
und die API dieselbe Berechnung nutzen (Phase C, Issue 53).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.nutrition_metrics import (
|
||||
get_energy_balance_data,
|
||||
get_protein_adequacy_data,
|
||||
get_protein_targets_data,
|
||||
)
|
||||
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
||||
|
||||
|
||||
def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""E1 Energiebilanz — identisch zu GET /api/charts/energy-balance."""
|
||||
balance_meta = get_energy_balance_data(profile_id, days)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(kcal)::float AS kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 3:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)",
|
||||
},
|
||||
}
|
||||
|
||||
estimated_tdee = balance_meta.get("estimated_tdee") or 0
|
||||
if estimated_tdee <= 0:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows),
|
||||
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)",
|
||||
},
|
||||
}
|
||||
|
||||
labels = []
|
||||
daily_values = []
|
||||
avg_7d = []
|
||||
avg_14d = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
labels.append(row["date"].isoformat())
|
||||
daily_values.append(safe_float(row["kcal"]))
|
||||
|
||||
start_7d = max(0, i - 6)
|
||||
window_7d = [safe_float(rows[j]["kcal"]) for j in range(start_7d, i + 1)]
|
||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||
|
||||
start_14d = max(0, i - 13)
|
||||
window_14d = [safe_float(rows[j]["kcal"]) for j in range(start_14d, i + 1)]
|
||||
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
|
||||
|
||||
avg_intake = float(
|
||||
balance_meta.get("avg_intake")
|
||||
or (sum(daily_values) / len(daily_values) if daily_values else 0)
|
||||
)
|
||||
energy_balance = float(
|
||||
balance_meta.get("energy_balance") or (avg_intake - estimated_tdee)
|
||||
)
|
||||
balance_status = balance_meta.get("status") or (
|
||||
"deficit"
|
||||
if energy_balance < -200
|
||||
else "surplus"
|
||||
if energy_balance > 200
|
||||
else "maintenance"
|
||||
)
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Kalorien (täglich)",
|
||||
"data": daily_values,
|
||||
"borderColor": "#1D9E7599",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 1.5,
|
||||
"tension": 0.2,
|
||||
"fill": False,
|
||||
"pointRadius": 2,
|
||||
},
|
||||
{
|
||||
"label": "Ø 7 Tage",
|
||||
"data": avg_7d,
|
||||
"borderColor": "#1D9E75",
|
||||
"borderWidth": 2.5,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
},
|
||||
{
|
||||
"label": "Ø 14 Tage",
|
||||
"data": avg_14d,
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"borderDash": [6, 3],
|
||||
},
|
||||
{
|
||||
"label": "TDEE (geschätzt)",
|
||||
"data": [estimated_tdee] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
},
|
||||
]
|
||||
|
||||
confidence = balance_meta.get("confidence") or "low"
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": labels, "datasets": datasets},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": confidence,
|
||||
"data_points": len(rows),
|
||||
"avg_kcal": round(avg_intake, 1),
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"energy_balance": round(energy_balance, 1),
|
||||
"balance_status": balance_status,
|
||||
"first_date": rows[0]["date"],
|
||||
"last_date": rows[-1]["date"],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy."""
|
||||
targets = get_protein_targets_data(profile_id)
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(protein_g)::float AS protein_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 3:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Protein-Daten (min. 3 Tage)",
|
||||
},
|
||||
}
|
||||
|
||||
labels = []
|
||||
daily_values = []
|
||||
avg_7d = []
|
||||
avg_28d = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
labels.append(row["date"].isoformat())
|
||||
daily_values.append(safe_float(row["protein_g"]))
|
||||
|
||||
start_7d = max(0, i - 6)
|
||||
window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)]
|
||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||
|
||||
start_28d = max(0, i - 27)
|
||||
window_28d = [safe_float(rows[j]["protein_g"]) for j in range(start_28d, i + 1)]
|
||||
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
|
||||
|
||||
target_low = targets["protein_target_low"]
|
||||
target_high = targets["protein_target_high"]
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Protein (täglich)",
|
||||
"data": daily_values,
|
||||
"borderColor": "#1D9E7599",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 1.5,
|
||||
"tension": 0.2,
|
||||
"fill": False,
|
||||
"pointRadius": 2,
|
||||
},
|
||||
{
|
||||
"label": "Ø 7 Tage",
|
||||
"data": avg_7d,
|
||||
"borderColor": "#1D9E75",
|
||||
"borderWidth": 2.5,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
},
|
||||
{
|
||||
"label": "Ø 28 Tage",
|
||||
"data": avg_28d,
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"borderDash": [6, 3],
|
||||
},
|
||||
{
|
||||
"label": "Ziel Min",
|
||||
"data": [target_low] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
},
|
||||
]
|
||||
|
||||
datasets.append(
|
||||
{
|
||||
"label": "Ziel Max",
|
||||
"data": [target_high] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
}
|
||||
)
|
||||
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
|
||||
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": labels, "datasets": datasets},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": confidence,
|
||||
"data_points": len(rows),
|
||||
"target_low": round(target_low, 1),
|
||||
"target_high": round(target_high, 1),
|
||||
"days_in_target": days_in_target,
|
||||
"target_compliance_pct": round(
|
||||
days_in_target / len(daily_values) * 100, 1
|
||||
)
|
||||
if daily_values
|
||||
else 0,
|
||||
"first_date": rows[0]["date"],
|
||||
"last_date": rows[-1]["date"],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
|
||||
profile_row = cur.fetchone()
|
||||
goal_mode = (
|
||||
profile_row["goal_mode"]
|
||||
if profile_row and profile_row["goal_mode"]
|
||||
else "health"
|
||||
)
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
cur.execute(
|
||||
"""WITH daily AS (
|
||||
SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS dc,
|
||||
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
)
|
||||
SELECT COUNT(*)::int AS cnt,
|
||||
AVG(dk) AS avg_kcal,
|
||||
STDDEV(dk) AS std_kcal,
|
||||
AVG(dp) AS avg_protein,
|
||||
AVG(dc) AS avg_carbs,
|
||||
AVG(df) AS avg_fat
|
||||
FROM daily""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
stats = cur.fetchone()
|
||||
|
||||
if not stats or stats["cnt"] < 7:
|
||||
return {
|
||||
"score": 0,
|
||||
"components": {},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"message": "Nicht genug Daten (min. 7 Tage)",
|
||||
},
|
||||
}
|
||||
|
||||
protein_data = get_protein_adequacy_data(profile_id, days)
|
||||
|
||||
calorie_adherence = 70.0
|
||||
protein_adequacy_pct = protein_data.get("adequacy_score", 0)
|
||||
protein_adherence = min(100, protein_adequacy_pct)
|
||||
|
||||
kcal_cv = (
|
||||
(safe_float(stats["std_kcal"]) / safe_float(stats["avg_kcal"]) * 100)
|
||||
if safe_float(stats["avg_kcal"]) > 0
|
||||
else 100
|
||||
)
|
||||
intake_consistency = max(0, 100 - kcal_cv)
|
||||
|
||||
food_quality = 60.0
|
||||
|
||||
if goal_mode == "weight_loss":
|
||||
weights = {
|
||||
"calorie": 0.35,
|
||||
"protein": 0.25,
|
||||
"consistency": 0.20,
|
||||
"quality": 0.20,
|
||||
}
|
||||
elif goal_mode == "strength":
|
||||
weights = {
|
||||
"calorie": 0.25,
|
||||
"protein": 0.35,
|
||||
"consistency": 0.20,
|
||||
"quality": 0.20,
|
||||
}
|
||||
elif goal_mode == "endurance":
|
||||
weights = {
|
||||
"calorie": 0.30,
|
||||
"protein": 0.20,
|
||||
"consistency": 0.20,
|
||||
"quality": 0.30,
|
||||
}
|
||||
else:
|
||||
weights = {
|
||||
"calorie": 0.25,
|
||||
"protein": 0.25,
|
||||
"consistency": 0.25,
|
||||
"quality": 0.25,
|
||||
}
|
||||
|
||||
final_score = (
|
||||
calorie_adherence * weights["calorie"]
|
||||
+ protein_adherence * weights["protein"]
|
||||
+ intake_consistency * weights["consistency"]
|
||||
+ food_quality * weights["quality"]
|
||||
)
|
||||
|
||||
components = {
|
||||
"calorie_adherence": round(calorie_adherence, 1),
|
||||
"protein_adherence": round(protein_adherence, 1),
|
||||
"intake_consistency": round(intake_consistency, 1),
|
||||
"food_quality": round(food_quality, 1),
|
||||
}
|
||||
|
||||
weak_areas = [k for k, v in components.items() if v < 60]
|
||||
if weak_areas:
|
||||
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
|
||||
else:
|
||||
recommendation = "Gute Adhärenz, weiter so!"
|
||||
|
||||
return {
|
||||
"score": round(final_score, 1),
|
||||
"components": components,
|
||||
"goal_mode": goal_mode,
|
||||
"weights": weights,
|
||||
"recommendation": recommendation,
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(stats["cnt"], days, "general"),
|
||||
"data_points": stats["cnt"],
|
||||
"days_analyzed": days,
|
||||
},
|
||||
}
|
||||
|
|
@ -17,6 +17,11 @@ from data_layer.nutrition_interpretation import (
|
|||
build_nutrition_correlation_heuristic_items,
|
||||
build_nutrition_history_kpi_tiles,
|
||||
)
|
||||
from data_layer.nutrition_chart_payloads import (
|
||||
build_energy_balance_chart_payload,
|
||||
build_nutrition_adherence_score_payload,
|
||||
build_protein_adequacy_chart_payload,
|
||||
)
|
||||
from data_layer.nutrition_metrics import (
|
||||
estimate_tdee_kcal_from_latest_weight,
|
||||
get_energy_availability_warning_payload,
|
||||
|
|
@ -244,6 +249,8 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"calorie_balance_daily": [],
|
||||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
||||
"nutrition_correlation_heuristics": [],
|
||||
"chart_payloads": {},
|
||||
"chart_payloads_days": None,
|
||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
||||
}
|
||||
|
||||
|
|
@ -312,6 +319,20 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
||||
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
||||
|
||||
# E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf)
|
||||
days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90))
|
||||
chart_payloads = {
|
||||
"energy_balance": build_energy_balance_chart_payload(
|
||||
profile_id, days_for_embedded_charts
|
||||
),
|
||||
"protein_adequacy": build_protein_adequacy_chart_payload(
|
||||
profile_id, days_for_embedded_charts
|
||||
),
|
||||
"nutrition_adherence": build_nutrition_adherence_score_payload(
|
||||
profile_id, days_for_embedded_charts
|
||||
),
|
||||
}
|
||||
|
||||
conf = navg.get("confidence") or "medium"
|
||||
if targets.get("confidence") == "insufficient":
|
||||
conf = "insufficient"
|
||||
|
|
@ -362,6 +383,8 @@ def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
||||
},
|
||||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
||||
"chart_payloads": chart_payloads,
|
||||
"chart_payloads_days": days_for_embedded_charts,
|
||||
"meta": {
|
||||
"layer_1": "nutrition_metrics",
|
||||
"layer_2b": "nutrition_viz",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ from data_layer.body_metrics import (
|
|||
)
|
||||
from data_layer.body_viz import get_body_history_viz_bundle
|
||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle, get_activity_last_updated_iso
|
||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
||||
from data_layer.history_overview_viz import get_history_overview_viz_bundle
|
||||
from data_layer.recovery_chart_payloads import (
|
||||
|
|
@ -46,9 +46,7 @@ from data_layer.recovery_chart_payloads import (
|
|||
from data_layer.nutrition_metrics import (
|
||||
get_nutrition_average_data,
|
||||
get_protein_targets_data,
|
||||
get_protein_adequacy_data,
|
||||
get_macro_consistency_data,
|
||||
get_energy_balance_data,
|
||||
get_weekly_macro_distribution_chart_data,
|
||||
get_energy_availability_warning_payload,
|
||||
)
|
||||
|
|
@ -77,6 +75,11 @@ from data_layer.correlations import (
|
|||
calculate_top_drivers
|
||||
)
|
||||
from data_layer.utils import serialize_dates, safe_float, calculate_confidence
|
||||
from data_layer.nutrition_chart_payloads import (
|
||||
build_energy_balance_chart_payload,
|
||||
build_protein_adequacy_chart_payload,
|
||||
build_nutrition_adherence_score_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/charts", tags=["charts"])
|
||||
|
||||
|
|
@ -319,6 +322,17 @@ def get_fitness_dashboard_viz(
|
|||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/activity-last-updated")
|
||||
def get_activity_last_updated(session: dict = Depends(require_auth)) -> Dict:
|
||||
"""
|
||||
Minimal-Metadatum: letztes Trainingsdatum — gleiche Quelle wie ``last_updated`` im Fitness-Viz-Bundle.
|
||||
|
||||
Vermeidet Massen-Ladevorgänge (z. B. listActivity) nur für Datumsanzeige im Verlauf.
|
||||
"""
|
||||
pid = session["profile_id"]
|
||||
return {"last_activity_date": get_activity_last_updated_iso(pid)}
|
||||
|
||||
|
||||
@router.get("/recovery-dashboard-viz")
|
||||
def get_recovery_dashboard_viz(
|
||||
days: int = Query(
|
||||
|
|
@ -451,136 +465,7 @@ def get_energy_balance_chart(
|
|||
Chart.js line chart with multiple datasets
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
balance_meta = get_energy_balance_data(profile_id, days)
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(kcal)::float AS kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 3:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)"
|
||||
}
|
||||
}
|
||||
|
||||
estimated_tdee = balance_meta.get("estimated_tdee") or 0
|
||||
if estimated_tdee <= 0:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows),
|
||||
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)"
|
||||
}
|
||||
}
|
||||
|
||||
labels = []
|
||||
daily_values = []
|
||||
avg_7d = []
|
||||
avg_14d = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
labels.append(row['date'].isoformat())
|
||||
daily_values.append(safe_float(row['kcal']))
|
||||
|
||||
start_7d = max(0, i - 6)
|
||||
window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)]
|
||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||
|
||||
start_14d = max(0, i - 13)
|
||||
window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)]
|
||||
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
|
||||
|
||||
avg_intake = float(balance_meta.get("avg_intake") or (sum(daily_values) / len(daily_values) if daily_values else 0))
|
||||
energy_balance = float(balance_meta.get("energy_balance") or (avg_intake - estimated_tdee))
|
||||
balance_status = balance_meta.get("status") or (
|
||||
"deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance"
|
||||
)
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Kalorien (täglich)",
|
||||
"data": daily_values,
|
||||
"borderColor": "#1D9E7599",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 1.5,
|
||||
"tension": 0.2,
|
||||
"fill": False,
|
||||
"pointRadius": 2
|
||||
},
|
||||
{
|
||||
"label": "Ø 7 Tage",
|
||||
"data": avg_7d,
|
||||
"borderColor": "#1D9E75",
|
||||
"borderWidth": 2.5,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0
|
||||
},
|
||||
{
|
||||
"label": "Ø 14 Tage",
|
||||
"data": avg_14d,
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"borderDash": [6, 3]
|
||||
},
|
||||
{
|
||||
"label": "TDEE (geschätzt)",
|
||||
"data": [estimated_tdee] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0
|
||||
}
|
||||
]
|
||||
|
||||
confidence = balance_meta.get("confidence") or "low"
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": datasets
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": confidence,
|
||||
"data_points": len(rows),
|
||||
"avg_kcal": round(avg_intake, 1),
|
||||
"estimated_tdee": estimated_tdee,
|
||||
"energy_balance": round(energy_balance, 1),
|
||||
"balance_status": balance_status,
|
||||
"first_date": rows[0]['date'],
|
||||
"last_date": rows[-1]['date']
|
||||
})
|
||||
}
|
||||
return build_energy_balance_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/macro-distribution")
|
||||
|
|
@ -695,136 +580,7 @@ def get_protein_adequacy_chart(
|
|||
Chart.js line chart with protein intake + averages + target bands
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# Get protein targets
|
||||
targets = get_protein_targets_data(profile_id)
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(protein_g)::float AS protein_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 3:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Protein-Daten (min. 3 Tage)"
|
||||
}
|
||||
}
|
||||
|
||||
# Prepare data
|
||||
labels = []
|
||||
daily_values = []
|
||||
avg_7d = []
|
||||
avg_28d = []
|
||||
|
||||
for i, row in enumerate(rows):
|
||||
labels.append(row['date'].isoformat())
|
||||
daily_values.append(safe_float(row['protein_g']))
|
||||
|
||||
# 7d rolling average
|
||||
start_7d = max(0, i - 6)
|
||||
window_7d = [safe_float(rows[j]['protein_g']) for j in range(start_7d, i + 1)]
|
||||
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||
|
||||
# 28d rolling average
|
||||
start_28d = max(0, i - 27)
|
||||
window_28d = [safe_float(rows[j]['protein_g']) for j in range(start_28d, i + 1)]
|
||||
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
|
||||
|
||||
# Add target range bands
|
||||
target_low = targets['protein_target_low']
|
||||
target_high = targets['protein_target_high']
|
||||
|
||||
datasets = [
|
||||
{
|
||||
"label": "Protein (täglich)",
|
||||
"data": daily_values,
|
||||
"borderColor": "#1D9E7599",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 1.5,
|
||||
"tension": 0.2,
|
||||
"fill": False,
|
||||
"pointRadius": 2
|
||||
},
|
||||
{
|
||||
"label": "Ø 7 Tage",
|
||||
"data": avg_7d,
|
||||
"borderColor": "#1D9E75",
|
||||
"borderWidth": 2.5,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0
|
||||
},
|
||||
{
|
||||
"label": "Ø 28 Tage",
|
||||
"data": avg_28d,
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": False,
|
||||
"pointRadius": 0,
|
||||
"borderDash": [6, 3]
|
||||
},
|
||||
{
|
||||
"label": "Ziel Min",
|
||||
"data": [target_low] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0
|
||||
}
|
||||
]
|
||||
|
||||
datasets.append({
|
||||
"label": "Ziel Max",
|
||||
"data": [target_high] * len(labels),
|
||||
"borderColor": "#888",
|
||||
"borderWidth": 1,
|
||||
"borderDash": [5, 5],
|
||||
"fill": False,
|
||||
"pointRadius": 0
|
||||
})
|
||||
|
||||
from data_layer.utils import calculate_confidence
|
||||
confidence = calculate_confidence(len(rows), days, "general")
|
||||
|
||||
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": datasets
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": confidence,
|
||||
"data_points": len(rows),
|
||||
"target_low": round(target_low, 1),
|
||||
"target_high": round(target_high, 1),
|
||||
"days_in_target": days_in_target,
|
||||
"target_compliance_pct": round(days_in_target / len(daily_values) * 100, 1) if daily_values else 0,
|
||||
"first_date": rows[0]['date'],
|
||||
"last_date": rows[-1]['date']
|
||||
})
|
||||
}
|
||||
return build_protein_adequacy_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/nutrition-consistency")
|
||||
|
|
@ -952,138 +708,7 @@ def get_nutrition_adherence_score(
|
|||
}
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.nutrition_metrics import (
|
||||
get_protein_adequacy_data,
|
||||
calculate_macro_consistency_score
|
||||
)
|
||||
|
||||
# Get user's goal mode (weight_loss, strength, endurance, etc.)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
|
||||
profile_row = cur.fetchone()
|
||||
goal_mode = profile_row['goal_mode'] if profile_row and profile_row['goal_mode'] else 'health'
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Get nutrition data
|
||||
cur.execute(
|
||||
"""WITH daily AS (
|
||||
SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS dk,
|
||||
COALESCE(SUM(protein_g), 0)::float AS dp,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS dc,
|
||||
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date
|
||||
)
|
||||
SELECT COUNT(*)::int AS cnt,
|
||||
AVG(dk) AS avg_kcal,
|
||||
STDDEV(dk) AS std_kcal,
|
||||
AVG(dp) AS avg_protein,
|
||||
AVG(dc) AS avg_carbs,
|
||||
AVG(df) AS avg_fat
|
||||
FROM daily""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
stats = cur.fetchone()
|
||||
|
||||
if not stats or stats['cnt'] < 7:
|
||||
return {
|
||||
"score": 0,
|
||||
"components": {},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"message": "Nicht genug Daten (min. 7 Tage)"
|
||||
}
|
||||
}
|
||||
|
||||
# Get protein adequacy
|
||||
protein_data = get_protein_adequacy_data(profile_id, days)
|
||||
|
||||
# Calculate components based on goal mode
|
||||
components = {}
|
||||
|
||||
# 1. Calorie adherence (placeholder, needs goal-specific logic)
|
||||
calorie_adherence = 70.0 # TODO: Calculate based on TDEE target
|
||||
|
||||
# 2. Protein adherence
|
||||
protein_adequacy_pct = protein_data.get('adequacy_score', 0)
|
||||
protein_adherence = min(100, protein_adequacy_pct)
|
||||
|
||||
# 3. Intake consistency (low volatility = good)
|
||||
kcal_cv = (safe_float(stats['std_kcal']) / safe_float(stats['avg_kcal']) * 100) if safe_float(stats['avg_kcal']) > 0 else 100
|
||||
intake_consistency = max(0, 100 - kcal_cv) # Invert: low CV = high score
|
||||
|
||||
# 4. Food quality (placeholder for fiber/sugar analysis)
|
||||
food_quality = 60.0 # TODO: Calculate from fiber/sugar data
|
||||
|
||||
# Goal-specific weighting (from concept E4)
|
||||
if goal_mode == 'weight_loss':
|
||||
weights = {
|
||||
'calorie': 0.35,
|
||||
'protein': 0.25,
|
||||
'consistency': 0.20,
|
||||
'quality': 0.20
|
||||
}
|
||||
elif goal_mode == 'strength':
|
||||
weights = {
|
||||
'calorie': 0.25,
|
||||
'protein': 0.35,
|
||||
'consistency': 0.20,
|
||||
'quality': 0.20
|
||||
}
|
||||
elif goal_mode == 'endurance':
|
||||
weights = {
|
||||
'calorie': 0.30,
|
||||
'protein': 0.20,
|
||||
'consistency': 0.20,
|
||||
'quality': 0.30
|
||||
}
|
||||
else: # health, recomposition
|
||||
weights = {
|
||||
'calorie': 0.25,
|
||||
'protein': 0.25,
|
||||
'consistency': 0.25,
|
||||
'quality': 0.25
|
||||
}
|
||||
|
||||
# Calculate weighted score
|
||||
final_score = (
|
||||
calorie_adherence * weights['calorie'] +
|
||||
protein_adherence * weights['protein'] +
|
||||
intake_consistency * weights['consistency'] +
|
||||
food_quality * weights['quality']
|
||||
)
|
||||
|
||||
components = {
|
||||
'calorie_adherence': round(calorie_adherence, 1),
|
||||
'protein_adherence': round(protein_adherence, 1),
|
||||
'intake_consistency': round(intake_consistency, 1),
|
||||
'food_quality': round(food_quality, 1)
|
||||
}
|
||||
|
||||
# Generate recommendation
|
||||
weak_areas = [k for k, v in components.items() if v < 60]
|
||||
if weak_areas:
|
||||
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
|
||||
else:
|
||||
recommendation = "Gute Adhärenz, weiter so!"
|
||||
|
||||
return {
|
||||
"score": round(final_score, 1),
|
||||
"components": components,
|
||||
"goal_mode": goal_mode,
|
||||
"weights": weights,
|
||||
"recommendation": recommendation,
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(stats['cnt'], days, "general"),
|
||||
"data_points": stats['cnt'],
|
||||
"days_analyzed": days
|
||||
}
|
||||
}
|
||||
return build_nutrition_adherence_score_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/energy-availability-warning")
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ Semantic Versioning: MAJOR.MINOR.PATCH
|
|||
- PATCH: Bugfix, kleine Änderung, Refactor
|
||||
"""
|
||||
|
||||
APP_VERSION = "0.9q"
|
||||
BUILD_DATE = "2026-04-11"
|
||||
APP_VERSION = "0.9t"
|
||||
BUILD_DATE = "2026-04-20"
|
||||
DB_SCHEMA_VERSION = "20260409c" # 048/049 vitals_baseline.source csv + SAVEPOINT Import
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
|
|
@ -36,6 +36,31 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.9t",
|
||||
"date": "2026-04-20",
|
||||
"changes": [
|
||||
"Phase C: data_layer/nutrition_chart_payloads (E1/E2/E4) — gemeinsam mit /api/charts/*",
|
||||
"nutrition-history-viz: chart_payloads + chart_payloads_days; Verlauf NutritionCharts ohne 3 Extra-HTTP-Calls",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.9s",
|
||||
"date": "2026-04-20",
|
||||
"changes": [
|
||||
"Phase B: body-history-viz weight.trend_kpi (Gewichtstrend-Urteil im data_layer/body_viz)",
|
||||
"History Körper-KPIs: keine Client-Schwellen für WHR/WHtR; KF%-Farbe über Interpretations-Status",
|
||||
"Kcal vs. Gewicht: kein Frontend-TDEE-Fallback; Hinweis bei <5 gemeinsamen Tagen",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.9r",
|
||||
"date": "2026-04-20",
|
||||
"changes": [
|
||||
"History Phase A: GET /api/charts/activity-last-updated (data_layer fitness_viz, gleiche Quelle wie last_updated im Fitness-Bundle)",
|
||||
"History: entfernt toten Initial-Load listWeight/listCaliper/listCirc/listNutrition/listActivity(25k); Profil/Insights/Prompts + Activity-Datum",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.9q",
|
||||
"date": "2026-04-11",
|
||||
|
|
|
|||
|
|
@ -205,12 +205,14 @@ export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }
|
|||
|
||||
/**
|
||||
* Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird.
|
||||
* @param {object} [prefetchedChartPayloads] — aus GET /charts/nutrition-history-viz (`chart_payloads`): E1/E2/E4 ohne Extra-Requests (Phase C).
|
||||
*/
|
||||
export default function NutritionCharts({
|
||||
days = 28,
|
||||
showWeeklyMacroDistribution = true,
|
||||
/** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */
|
||||
hideEnergyAvailabilityCard = false,
|
||||
prefetchedChartPayloads = null,
|
||||
}) {
|
||||
const [energyData, setEnergyData] = useState(null)
|
||||
const [proteinData, setProteinData] = useState(null)
|
||||
|
|
@ -226,20 +228,44 @@ export default function NutritionCharts({
|
|||
|
||||
useEffect(() => {
|
||||
loadCharts()
|
||||
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard])
|
||||
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard, prefetchedChartPayloads])
|
||||
|
||||
const loadCharts = async () => {
|
||||
const tasks = [
|
||||
loadEnergyBalance(),
|
||||
loadProteinAdequacy(),
|
||||
loadAdherence(),
|
||||
]
|
||||
const p = prefetchedChartPayloads
|
||||
const tasks = []
|
||||
|
||||
if (p?.energy_balance) {
|
||||
setEnergyData(p.energy_balance)
|
||||
setLoading((l) => ({ ...l, energy: false }))
|
||||
setErrors((e) => ({ ...e, energy: null }))
|
||||
} else {
|
||||
tasks.push(loadEnergyBalance())
|
||||
}
|
||||
|
||||
if (p?.protein_adequacy) {
|
||||
setProteinData(p.protein_adequacy)
|
||||
setLoading((l) => ({ ...l, protein: false }))
|
||||
setErrors((e) => ({ ...e, protein: null }))
|
||||
} else {
|
||||
tasks.push(loadProteinAdequacy())
|
||||
}
|
||||
|
||||
if (showWeeklyMacroDistribution) {
|
||||
tasks.push(loadMacroWeekly())
|
||||
}
|
||||
|
||||
if (p?.nutrition_adherence) {
|
||||
setAdherenceData(p.nutrition_adherence)
|
||||
setLoading((l) => ({ ...l, adherence: false }))
|
||||
setErrors((e) => ({ ...e, adherence: null }))
|
||||
} else {
|
||||
tasks.push(loadAdherence())
|
||||
}
|
||||
|
||||
if (!hideEnergyAvailabilityCard) {
|
||||
tasks.push(loadWarning())
|
||||
}
|
||||
if (showWeeklyMacroDistribution) {
|
||||
tasks.splice(2, 0, loadMacroWeekly())
|
||||
}
|
||||
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
import { ChevronRight, Brain, ChevronDown, ChevronUp, Trash2 } from 'lucide-react'
|
||||
import { api } from '../utils/api'
|
||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||
import { getBfCategory } from '../utils/calc'
|
||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||
import Markdown from '../utils/Markdown'
|
||||
|
|
@ -22,12 +21,6 @@ import dayjs from 'dayjs'
|
|||
import 'dayjs/locale/de'
|
||||
dayjs.locale('de')
|
||||
|
||||
function rollingAvg(arr, key, window=7) {
|
||||
return arr.map((d,i) => {
|
||||
const s = arr.slice(Math.max(0,i-window+1),i+1).map(x=>x[key]).filter(v=>v!=null)
|
||||
return s.length ? {...d,[`${key}_avg`]:Math.round(s.reduce((a,b)=>a+b)/s.length*10)/10} : d
|
||||
})
|
||||
}
|
||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
||||
|
||||
function NavToCaliper() {
|
||||
|
|
@ -94,23 +87,14 @@ function verdictShort(status) {
|
|||
return 'Achtung'
|
||||
}
|
||||
|
||||
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln (ohne Duplikate zur reinen Bewertungsliste). */
|
||||
/** Eine KPI-Kachelzeile aus Summary + Interpretationsregeln — Trend-Urteil aus Bundle ``weight.trend_kpi`` (Layer 1). */
|
||||
function buildBodyKpiTiles({
|
||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, sex, bfCat, goalW,
|
||||
summary, rules, trendPeriods, minW, maxW, avgAll, dataPoints, weightTrendKpi, goalW,
|
||||
}) {
|
||||
const tiles = []
|
||||
|
||||
if (summary.weight_kg != null) {
|
||||
const t90 = trendPeriods.find(t => t.label === '90T')
|
||||
const t30 = trendPeriods.find(t => t.label === '30T')
|
||||
const d = t90?.diff_kg ?? t30?.diff_kg ?? trendPeriods[0]?.diff_kg
|
||||
let st = 'good'
|
||||
let vs = 'Stabil'
|
||||
if (d != null) {
|
||||
if (d < -0.25) { st = 'good'; vs = 'Trend ↓' }
|
||||
else if (d > 0.25) { st = 'warn'; vs = 'Trend ↑' }
|
||||
else { st = 'good'; vs = 'Stabil' }
|
||||
}
|
||||
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(' · ')
|
||||
: ''
|
||||
|
|
@ -128,8 +112,8 @@ function buildBodyKpiTiles({
|
|||
icon: '⚖️',
|
||||
value: `${summary.weight_kg} kg`,
|
||||
sublabel: dataPoints ? `${dataPoints} Messwerte` : '',
|
||||
verdict: vs,
|
||||
status: st,
|
||||
verdict: wt.verdict,
|
||||
status: wt.status,
|
||||
hoverTop: 'Gewicht',
|
||||
hoverBody,
|
||||
keys: ['weight_aktuell', 'weight_trend'],
|
||||
|
|
@ -143,8 +127,8 @@ function buildBodyKpiTiles({
|
|||
category: 'Körperfett',
|
||||
icon: '🫧',
|
||||
value: `${summary.body_fat_pct}%`,
|
||||
valueColor: bfCat?.color,
|
||||
sublabel: bfCat?.label || summary.bf_category_label || '',
|
||||
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',
|
||||
|
|
@ -186,34 +170,32 @@ function buildBodyKpiTiles({
|
|||
}
|
||||
|
||||
const whrRule = rules.find(r => r.category === 'Fettverteilung')
|
||||
if (summary.whr != null) {
|
||||
const ok = summary.whr < (sex === 'm' ? 0.9 : 0.85)
|
||||
if (summary.whr != null && whrRule) {
|
||||
tiles.push({
|
||||
key: 'whr',
|
||||
category: 'Fettverteilung',
|
||||
icon: '📐',
|
||||
value: String(summary.whr),
|
||||
sublabel: 'WHR · Taille ÷ Hüfte',
|
||||
verdict: whrRule ? verdictShort(whrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
||||
status: whrRule?.status || (ok ? 'good' : 'warn'),
|
||||
hoverTop: whrRule?.title || 'Waist-Hip-Ratio',
|
||||
hoverBody: [whrRule?.detail, !whrRule && `Ziel unter ${sex === 'm' ? '0,90' : '0,85'}.`, whrRule?.related_placeholder_keys?.length ? `Registry: ${whrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
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) {
|
||||
const ok = summary.whtr < 0.5
|
||||
if (summary.whtr != null && whtrRule) {
|
||||
tiles.push({
|
||||
key: 'whtr',
|
||||
category: 'Taille/Größe',
|
||||
icon: '📏',
|
||||
value: String(summary.whtr),
|
||||
sublabel: 'WHtR · Taille ÷ Größe',
|
||||
verdict: whtrRule ? verdictShort(whtrRule.status) : (ok ? 'Gut' : 'Hinweis'),
|
||||
status: whtrRule?.status || (ok ? 'good' : 'warn'),
|
||||
hoverTop: whtrRule?.title || 'Waist-to-Height-Ratio',
|
||||
hoverBody: [whtrRule?.detail, !whtrRule && 'Ziel unter 0,50 (WHO).', whtrRule?.related_placeholder_keys?.length ? `Registry: ${whtrRule.related_placeholder_keys.join(', ')}` : ''].filter(Boolean).join('\n\n'),
|
||||
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'),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -399,8 +381,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
const [vizLoading, setVizLoading] = useState(true)
|
||||
const [vizError, setVizError] = useState(null)
|
||||
|
||||
const sex = profile?.sex || 'm'
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
api.listGoalsGrouped()
|
||||
|
|
@ -470,7 +450,6 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
belly: r.belly,
|
||||
}))
|
||||
|
||||
const bfCat = summary.body_fat_pct != null ? getBfCategory(summary.body_fat_pct, sex) : null
|
||||
const goalW = viz?.profile?.goal_weight_kg ?? profile?.goal_weight
|
||||
const goalBf = viz?.profile?.goal_bf_pct ?? profile?.goal_bf_pct
|
||||
|
||||
|
|
@ -492,8 +471,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
maxW,
|
||||
avgAll,
|
||||
dataPoints: w?.data_points,
|
||||
sex,
|
||||
bfCat,
|
||||
weightTrendKpi: w?.trend_kpi,
|
||||
goalW,
|
||||
})
|
||||
|
||||
|
|
@ -785,75 +763,31 @@ function KcalVsWeightLegend({ showTdee }) {
|
|||
)
|
||||
}
|
||||
|
||||
/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */
|
||||
function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) {
|
||||
if (vizKcalWeight?.points?.length >= 5) {
|
||||
const tdee = vizKcalWeight.tdee_reference_kcal
|
||||
const kcalVsW = vizKcalWeight.points.map(d => ({
|
||||
...d,
|
||||
date: fmtDate(d.date),
|
||||
}))
|
||||
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
|
||||
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
||||
const kcalDomain = kcalVsWeightKcalDomain(kcalVsW, tdeeLabel)
|
||||
/** Kalorien (Ø 7T) vs. Gewicht — nur Layer-2b-Bundle (nutrition_metrics); kein Frontend-TDEE-Fallback. */
|
||||
function KcalVsWeightChart({ vizKcalWeight }) {
|
||||
const n = vizKcalWeight?.points?.length ?? 0
|
||||
if (n < 5) {
|
||||
if (n === 0) return null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<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: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<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, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === '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>
|
||||
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
||||
{tdeeLabel != null
|
||||
? `TDEE ~${tdeeLabel} kcal · ${n} gemeinsame Tage`
|
||||
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
|
||||
<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 raw = (corrRows || []).filter(d => {
|
||||
if (!d.kcal || d.weight == null) return false
|
||||
const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD')
|
||||
return allTime || ds >= cutoffDate
|
||||
})
|
||||
if (raw.length < 5) return null
|
||||
|
||||
const sex = profile?.sex || 'm'
|
||||
const height = profile?.height || 178
|
||||
const latestW = raw[raw.length - 1]?.weight || 80
|
||||
const age = profile?.dob ? Math.floor((Date.now() - new Date(profile.dob)) / (365.25 * 24 * 3600 * 1000)) : 35
|
||||
const bmr = sex === 'm' ? 10 * latestW + 6.25 * height - 5 * age + 5 : 10 * latestW + 6.25 * height - 5 * age - 161
|
||||
const tdee = Math.round(bmr * 1.4)
|
||||
const kcalVsW = rollingAvg(raw.map(d => ({ ...d, date: fmtDate(d.date) })), 'kcal')
|
||||
const kcalDomainFb = kcalVsWeightKcalDomain(kcalVsW, tdee)
|
||||
|
||||
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 }}>
|
||||
|
|
@ -866,27 +800,31 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
|
|||
<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={kcalDomainFb} />
|
||||
<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, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
||||
/>
|
||||
<ReferenceLine
|
||||
yAxisId="kcal"
|
||||
y={tdee}
|
||||
stroke={TDEE_REF_LINE_COLOR}
|
||||
strokeDasharray="6 5"
|
||||
strokeWidth={2}
|
||||
isFront
|
||||
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>
|
||||
<KcalVsWeightLegend showTdee />
|
||||
<KcalVsWeightLegend showTdee={tdeeLabel != null} />
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
|
||||
TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
|
||||
{tdeeLabel != null
|
||||
? `TDEE ~${tdeeLabel} kcal · ${commonDays} gemeinsame Tage`
|
||||
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${commonDays} gemeinsame Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -894,7 +832,7 @@ function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffD
|
|||
|
||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||||
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
|
||||
function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
function NutritionSection({ insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(30)
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
|
|
@ -1000,13 +938,7 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
|
||||
<KpiTilesOverview tiles={kpiTiles} />
|
||||
|
||||
<KcalVsWeightChart
|
||||
vizKcalWeight={viz.kcal_vs_weight}
|
||||
corrData={[]}
|
||||
profile={profile}
|
||||
cutoffDate=""
|
||||
allTime={period === 9999}
|
||||
/>
|
||||
<KcalVsWeightChart vizKcalWeight={viz.kcal_vs_weight} />
|
||||
|
||||
{balDaily.length > 0 && tdeeRef != null && (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
|
|
@ -1188,7 +1120,12 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
|
||||
Zeitverläufe (Energie & Protein)
|
||||
</div>
|
||||
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} hideEnergyAvailabilityCard />
|
||||
<NutritionCharts
|
||||
days={chartDays}
|
||||
showWeeklyMacroDistribution={false}
|
||||
hideEnergyAvailabilityCard
|
||||
prefetchedChartPayloads={viz.chart_payloads}
|
||||
/>
|
||||
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
</div>
|
||||
|
|
@ -1196,14 +1133,12 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
}
|
||||
|
||||
// ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─
|
||||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||||
function ActivitySection({ activityLastDate, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||||
const [period, setPeriod] = useState(30)
|
||||
const actList = activities || []
|
||||
const hasList = actList.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
|
||||
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={activityLastDate || undefined}/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Fitness und Erholung aus den Data-Layer-Bundles (Issue 53). Zeitraum-Buttons steuern beide Bereiche gleichzeitig.
|
||||
|
|
@ -1215,7 +1150,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
</div>
|
||||
<RecoveryDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||
|
||||
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||
{activityLastDate && globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
marginBottom: 12, padding:'8px 12px', borderRadius:8,
|
||||
|
|
@ -1735,11 +1670,7 @@ export default function History() {
|
|||
const { activeProfile } = useProfile() // Issue #31: Get global quality filter
|
||||
const location = useLocation?.() || {}
|
||||
const [tab, setTab] = useState((location.state?.tab) || 'overview')
|
||||
const [weights, setWeights] = useState([])
|
||||
const [calipers, setCalipers] = useState([])
|
||||
const [circs, setCircs] = useState([])
|
||||
const [nutrition, setNutrition] = useState([])
|
||||
const [activities, setActivities] = useState([])
|
||||
const [activityLastDate, setActivityLastDate] = useState(null)
|
||||
const [insights, setInsights] = useState([])
|
||||
const [prompts, setPrompts] = useState([])
|
||||
const [profile, setProfile] = useState(null)
|
||||
|
|
@ -1747,15 +1678,15 @@ export default function History() {
|
|||
const [loadingSlug,setLoadingSlug]= useState(null)
|
||||
|
||||
const loadAll = () => Promise.all([
|
||||
api.listWeight(365), api.listCaliper(), api.listCirc(),
|
||||
api.listNutrition(90), api.listActivity(25_000),
|
||||
api.latestInsights(), api.getProfile(),
|
||||
api.latestInsights(),
|
||||
api.getProfile(),
|
||||
api.listPrompts(),
|
||||
]).then(([w,ca,ci,n,a,ins,p,pr])=>{
|
||||
setWeights(w); setCalipers(ca); setCircs(ci)
|
||||
setNutrition(n); setActivities(a)
|
||||
setInsights(Array.isArray(ins)?ins:[]); setProfile(p)
|
||||
setPrompts(Array.isArray(pr)?pr:[])
|
||||
api.getActivityLastUpdated(),
|
||||
]).then(([ins, p, pr, actMeta]) => {
|
||||
setInsights(Array.isArray(ins) ? ins : [])
|
||||
setProfile(p)
|
||||
setPrompts(Array.isArray(pr) ? pr : [])
|
||||
setActivityLastDate(actMeta?.last_activity_date ?? null)
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
|
|
@ -1819,8 +1750,8 @@ export default function History() {
|
|||
<div className="history-content">
|
||||
{tab==='overview' && <HistoryOverviewSection {...sp}/>}
|
||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activityLastDate={activityLastDate} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='photos' && <PhotoGrid/>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -644,6 +644,8 @@ export const api = {
|
|||
/** Layer 2b: Erholung — KPI, Insights, Charts R1–R5 (recovery_metrics) */
|
||||
getRecoveryDashboardViz: (days=28) => req(`/charts/recovery-dashboard-viz?days=${days}`),
|
||||
getHistoryOverviewViz: (days=30) => req(`/charts/history-overview-viz?days=${days}`),
|
||||
/** Minimal: letztes activity_log.date — wie fitness-dashboard-viz.last_updated */
|
||||
getActivityLastUpdated: () => req('/charts/activity-last-updated'),
|
||||
getWeightEnergyCorrelationChart: (maxLag=14) => req(`/charts/weight-energy-correlation?max_lag=${maxLag}`),
|
||||
getLbmProteinCorrelationChart: (maxLag=14) => req(`/charts/lbm-protein-correlation?max_lag=${maxLag}`),
|
||||
getLoadVitalsCorrelationChart: (maxLag=14) => req(`/charts/load-vitals-correlation?max_lag=${maxLag}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user