feat: implement nutrition history visualization bundle and related API endpoint
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Added a new `nutrition_interpretation.py` file to handle KPI tile generation for nutrition history.
- Introduced `nutrition_viz.py` to create a visualization bundle for nutrition data, integrating metrics and historical analysis.
- Implemented `get_nutrition_history_viz` endpoint in `charts.py` to serve the new visualization data.
- Updated frontend components to fetch and display nutrition history data, enhancing user experience with detailed insights.
- Refactored existing logic to streamline data handling and improve overall performance.
This commit is contained in:
Lars 2026-04-19 17:20:24 +02:00
parent a8eafa8ba4
commit b96b1931db
6 changed files with 774 additions and 292 deletions

View File

@ -0,0 +1,180 @@
"""
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
für KpiTilesOverview (keys = related_placeholder_keys).
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def _verdict(status: str) -> str:
if status == "good":
return "Gut"
if status == "warn":
return "Hinweis"
return "Achtung"
def build_nutrition_history_kpi_tiles(
navg: Dict[str, Any],
targets: Dict[str, Any],
date_span_label: str,
n_days_with_entries: int,
) -> List[Dict[str, Any]]:
"""
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
"""
kcal_avg = round(float(navg.get("kcal_avg") or 0))
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
pt_low = round(float(targets.get("protein_target_low") or 0))
pt_high = round(float(targets.get("protein_target_high") or 0))
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
protein_ok = targets_ok and avg_protein >= pt_low
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
prot_pct = (
round(avg_protein * 4 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
kh_pct = (
round(avg_carbs * 4 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
fat_pct = (
round(avg_fat * 9 / total_macro_kcal * 100)
if total_macro_kcal > 0
else 0
)
tiles: List[Dict[str, Any]] = [
{
"key": "kcal",
"category": "Kalorien (Ø)",
"icon": "🔥",
"value": f"{kcal_avg} kcal",
"sublabel": date_span_label,
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliche tägliche Energie",
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
"keys": ["nutrition_score"],
},
{
"key": "carbs",
"category": "KH (Ø)",
"icon": "🌾",
"value": f"{avg_carbs} g",
"sublabel": "Kohlenhydrate / Tag",
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliche Kohlenhydrate",
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
"keys": ["nutrition_summary"],
},
{
"key": "fat",
"category": "Fett (Ø)",
"icon": "🧈",
"value": f"{avg_fat} g",
"sublabel": "Fett / Tag",
"status": "good",
"verdict": "Gut",
"hoverTop": "Durchschnittliches Fett",
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
"keys": ["nutrition_summary"],
},
]
if not targets_ok:
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": "Referenzgewicht fehlt",
"status": "warn",
"verdict": _verdict("warn"),
"hoverTop": "Protein-Ziel nicht berechenbar",
"hoverBody": "Für 1,62,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
"keys": ["protein_adequacy"],
}
)
elif not protein_ok:
miss = max(0, pt_low - round(avg_protein))
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}{pt_high}g)",
"status": "bad",
"verdict": _verdict("bad"),
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}{pt_high}g)",
"hoverBody": (
f"1,62,2g/kg KG. Fehlend: ~{miss}g täglich. "
"Konsequenz: Muskelverlust bei Defizit."
),
"keys": ["protein_adequacy", "nutrition_score"],
}
)
else:
tiles.append(
{
"key": "eval-protein",
"category": "Protein",
"icon": "🥩",
"value": f"{avg_protein}g",
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}{pt_high}g)",
"status": "good",
"verdict": _verdict("good"),
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}{pt_high}g)",
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
"keys": ["protein_adequacy", "nutrition_score"],
}
)
if prot_pct < 20 and total_macro_kcal > 0:
tiles.append(
{
"key": "eval-macro-pct",
"category": "Makro-Anteil",
"icon": "📊",
"value": f"{prot_pct}%",
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"status": "warn",
"verdict": _verdict("warn"),
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
"hoverBody": (
f"Empfehlung oft 2535%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
),
"keys": ["nutrition_summary"],
}
)
return tiles
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
"""Anteile in % der Makro-kcal + Gramm für Legende."""
p = float(navg.get("protein_avg") or 0)
c = float(navg.get("carbs_avg") or 0)
f = float(navg.get("fat_avg") or 0)
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
tot = pkcal + ckcal + fkcal
if tot <= 0:
return None
return [
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#059669", "grams": round(p, 1)},
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#EA580C", "grams": round(c, 1)},
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#2563EB", "grams": round(f, 1)},
]

View File

@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture
Version: 1.0
"""
import statistics
from typing import Dict, List, Optional
from datetime import datetime, timedelta, date
from db import get_db, get_cursor, r2d
@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str:
def get_nutrition_average_data(
profile_id: str,
days: int = 30
days: int = 30,
*,
all_history: bool = False,
) -> Dict:
"""
Get average nutrition values for all macros.
@ -136,11 +139,18 @@ def get_nutrition_average_data(
"""
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
# Mean over calendar days (per-day sums), not over raw log rows.
if cutoff:
inner_where = "WHERE profile_id=%s AND date >= %s"
params = (profile_id, cutoff)
else:
inner_where = "WHERE profile_id=%s"
params = (profile_id,)
cur.execute(
"""SELECT
f"""SELECT
AVG(daily_kcal) AS kcal_avg,
AVG(daily_protein) AS protein_avg,
AVG(daily_carbs) AS carbs_avg,
@ -153,10 +163,10 @@ def get_nutrition_average_data(
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
COALESCE(SUM(fat_g), 0)::float AS daily_fat
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
{inner_where}
GROUP BY date
) AS daily""",
(profile_id, cutoff),
params,
)
row = cur.fetchone()
@ -494,8 +504,6 @@ def get_macro_consistency_data(
"data_points": len(rows)
}
import statistics
protein_pcts = []
carbs_pcts = []
fat_pcts = []
@ -561,6 +569,136 @@ def get_macro_consistency_data(
}
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
"""
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
"""
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT date, protein_g, carbs_g, fat_g, kcal
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
AND fat_g IS NOT NULL AND kcal > 0
ORDER BY date""",
(profile_id, cutoff),
)
rows = cur.fetchall()
if not rows or len(rows) < 7:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": [],
},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows) if rows else 0,
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
},
}
weekly_data: Dict[str, Dict[str, List[float]]] = {}
for row in rows:
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
iso_week = date_obj.strftime("%Y-W%V")
if iso_week not in weekly_data:
weekly_data[iso_week] = {
"protein": [],
"carbs": [],
"fat": [],
"kcal": [],
}
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
labels: List[str] = []
protein_pcts: List[float] = []
carbs_pcts: List[float] = []
fat_pcts: List[float] = []
for iso_week in sorted(weekly_data.keys())[-weeks:]:
data = weekly_data[iso_week]
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
protein_kcal = avg_protein * 4
carbs_kcal = avg_carbs * 4
fat_kcal = avg_fat * 9
total_kcal = protein_kcal + carbs_kcal + fat_kcal
if total_kcal > 0:
labels.append(f"KW {iso_week[-2:]}")
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
protein_cv = (
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
else 0
)
carbs_cv = (
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
else 0
)
fat_cv = (
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
else 0
)
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Protein (%)",
"data": protein_pcts,
"backgroundColor": "#1D9E75",
"stack": "macro",
},
{
"label": "Kohlenhydrate (%)",
"data": carbs_pcts,
"backgroundColor": "#F59E0B",
"stack": "macro",
},
{
"label": "Fett (%)",
"data": fat_pcts,
"backgroundColor": "#EF4444",
"stack": "macro",
},
],
},
"metadata": {
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
"data_points": len(rows),
"weeks_analyzed": len(labels),
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
"protein_cv": round(protein_cv, 1),
"carbs_cv": round(carbs_cv, 1),
"fat_cv": round(fat_cv, 1),
},
}
# ============================================================================
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
# ============================================================================

View File

@ -0,0 +1,283 @@
"""
Layer 2b: Ernährungs-Verlauf ein Bundle für die UI (Issue #53).
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Any, Dict, List, Optional
from db import get_db, get_cursor, r2d
from data_layer.nutrition_interpretation import (
build_macro_donut_from_averages,
build_nutrition_history_kpi_tiles,
)
from data_layer.nutrition_metrics import (
estimate_tdee_kcal_from_latest_weight,
get_energy_balance_data,
get_nutrition_average_data,
get_protein_targets_data,
get_weekly_macro_distribution_chart_data,
)
from data_layer.utils import safe_float
def _cutoff_sql(days: int) -> Optional[str]:
if days >= 9999:
return None
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
def _iso(d: Any) -> Optional[str]:
if d is None:
return None
if hasattr(d, "isoformat"):
return d.isoformat()[:10]
return str(d)[:10]
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for i, d in enumerate(rows):
sl = rows[max(0, i - window + 1) : i + 1]
vals: List[float] = []
for x in sl:
v = safe_float(x.get(key))
if v is not None:
vals.append(v)
if not vals:
out.append({**d, f"{key}_avg": None})
continue
avg = round(sum(vals) / len(vals), 1)
out.append({**d, f"{key}_avg": avg})
return out
def _has_nutrition_entries(profile_id: str) -> bool:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
(profile_id,),
)
return cur.fetchone() is not None
def _last_nutrition_date(profile_id: str) -> Optional[str]:
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
(profile_id,),
)
row = cur.fetchone()
if not row or row["d"] is None:
return None
return _iso(row["d"])
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
with get_db() as conn:
cur = get_cursor(conn)
if cutoff:
cur.execute(
"""SELECT date,
COALESCE(SUM(kcal), 0)::float AS kcal,
COALESCE(SUM(protein_g), 0)::float AS protein_g,
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
COALESCE(SUM(fat_g), 0)::float AS fat_g
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
GROUP BY date
ORDER BY date ASC""",
(profile_id, cutoff),
)
else:
cur.execute(
"""SELECT date,
COALESCE(SUM(kcal), 0)::float AS kcal,
COALESCE(SUM(protein_g), 0)::float AS protein_g,
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
COALESCE(SUM(fat_g), 0)::float AS fat_g
FROM nutrition_log
WHERE profile_id=%s
GROUP BY date
ORDER BY date ASC""",
(profile_id,),
)
return [r2d(r) for r in cur.fetchall()]
def _kcal_weight_points_for_window(
profile_id: str, cutoff: Optional[str]
) -> List[Dict[str, Any]]:
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
with get_db() as conn:
cur = get_cursor(conn)
if cutoff:
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""",
(profile_id, cutoff),
)
else:
cur.execute(
"""SELECT date, SUM(kcal)::float AS kcal
FROM nutrition_log
WHERE profile_id=%s AND kcal IS NOT NULL
GROUP BY date""",
(profile_id,),
)
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
if cutoff:
cur.execute(
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
(profile_id, cutoff),
)
else:
cur.execute(
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
(profile_id,),
)
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
common = sorted(set(nk) & set(wk))
raw: List[Dict[str, Any]] = []
for ds in common:
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
rolled = _rolling_avg(raw, "kcal", 7)
out: List[Dict[str, Any]] = []
for r in rolled:
out.append(
{
"date": r["date"],
"kcal": r.get("kcal"),
"weight": r.get("weight"),
"kcal_avg": r.get("kcal_avg"),
}
)
return out
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
"""
Layer 2b Bundle für Verlauf «Ernährung».
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
"""
if not _has_nutrition_entries(profile_id):
return {
"confidence": "insufficient",
"has_nutrition_entries": False,
"message": "Noch keine Ernährungsdaten",
"kpi_tiles": [],
"summary": {},
"daily_macros": [],
"donut_avg_pct": None,
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
"weekly_macro_chart": {},
"tdee_reference_kcal": None,
"energy_balance_meta": {},
"interpretation_tiles": [],
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
}
all_history = days >= 9999
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
cutoff = _cutoff_sql(days)
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
targets = get_protein_targets_data(profile_id)
energy_days = eff_days if not all_history else min(9999, 3650)
energy_meta = get_energy_balance_data(profile_id, energy_days)
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
if tdee is None:
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
else:
tdee = float(tdee)
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
daily_macros: List[Dict[str, Any]] = []
for r in daily_rows:
daily_macros.append(
{
"date": _iso(r["date"]),
"kcal": round(safe_float(r.get("kcal")) or 0),
"Protein": round(safe_float(r.get("protein_g")) or 0),
"KH": round(safe_float(r.get("carbs_g")) or 0),
"Fett": round(safe_float(r.get("fat_g")) or 0),
}
)
date_span_label = ""
if daily_macros:
date_span_label = f"{daily_macros[0]['date']} {daily_macros[-1]['date']}"
n_days = int(navg.get("data_points") or 0)
kpi_tiles = build_nutrition_history_kpi_tiles(
navg, targets, date_span_label or "", max(1, n_days)
)
donut = build_macro_donut_from_averages(navg)
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
pt_low = round(float(targets.get("protein_target_low") or 0))
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
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)
conf = navg.get("confidence") or "medium"
if targets.get("confidence") == "insufficient":
conf = "insufficient"
return {
"confidence": conf,
"has_nutrition_entries": True,
"days_requested": days,
"effective_window_days": eff_days,
"nutrition_charts_days": chart_days_for_pipeline,
"weekly_macro_weeks_used": weeks_for_weekly,
"last_updated": _last_nutrition_date(profile_id),
"summary": {
"kcal_avg": navg.get("kcal_avg"),
"protein_avg": navg.get("protein_avg"),
"carbs_avg": navg.get("carbs_avg"),
"fat_avg": navg.get("fat_avg"),
"data_points": navg.get("data_points"),
"days_analyzed": navg.get("days_analyzed"),
"protein_target_low": targets.get("protein_target_low"),
"protein_target_high": targets.get("protein_target_high"),
"reference_weight_kg": targets.get("current_weight"),
},
"kpi_tiles": kpi_tiles,
"interpretation_tiles": [],
"daily_macros": daily_macros,
"donut_avg_pct": donut,
"protein_reference_line_g": pt_low,
"kcal_vs_weight": {
"points": kw_points,
"tdee_reference_kcal": tdee,
"common_days_count": len(kw_points),
},
"weekly_macro_chart": weekly_chart,
"tdee_reference_kcal": tdee,
"energy_balance_meta": {
"energy_balance": energy_meta.get("energy_balance"),
"avg_intake": energy_meta.get("avg_intake"),
"estimated_tdee": energy_meta.get("estimated_tdee"),
"status": energy_meta.get("status"),
"confidence": energy_meta.get("confidence"),
"data_points": energy_meta.get("data_points"),
},
"meta": {
"layer_1": "nutrition_metrics",
"layer_2b": "nutrition_viz",
"issue": "53-phase-0c",
},
}

View File

@ -32,12 +32,14 @@ from data_layer.body_metrics import (
get_circumference_summary_data
)
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.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,
)
from data_layer.activity_metrics import (
get_activity_summary_data,
@ -265,6 +267,26 @@ def get_body_history_viz(
return serialize_dates(bundle)
@router.get("/nutrition-history-viz")
def get_nutrition_history_viz(
days: int = Query(
default=90,
ge=7,
le=9999,
description="Analysefenster in Tagen (9999 = gesamte Historie)",
),
session: dict = Depends(require_auth),
) -> Dict:
"""
Layer 2b: Ein Bundle für Verlauf «Ernährung» Kennzahlen, Reihen, TDEE-Referenz, Wochen-Chart.
Alle Kennzahlen aus nutrition_metrics (gleiche Logik wie Platzhalter / Chart-Endpunkte).
"""
profile_id = session["profile_id"]
bundle = get_nutrition_history_viz_bundle(profile_id, days)
return serialize_dates(bundle)
@router.get("/circumferences")
def get_circumferences_chart(
max_age_days: int = Query(default=90, ge=7, le=365),
@ -830,136 +852,10 @@ def get_weekly_macro_distribution_chart(
Weekly macro distribution (E3) - Konzept-konform.
100%-gestapelter Wochenbalken statt Pie Chart.
Shows macro consistency across weeks, not just overall average.
Args:
weeks: Number of weeks to analyze (4-52, default 12)
session: Auth session (injected)
Returns:
Chart.js stacked bar chart with weekly macro percentages
Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data
"""
profile_id = session['profile_id']
from db import get_db, get_cursor
import statistics
with get_db() as conn:
cur = get_cursor(conn)
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
cur.execute(
"""SELECT date, protein_g, carbs_g, fat_g, kcal
FROM nutrition_log
WHERE profile_id=%s AND date >= %s
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
AND fat_g IS NOT NULL AND kcal > 0
ORDER BY date""",
(profile_id, cutoff)
)
rows = cur.fetchall()
if not rows or len(rows) < 7:
return {
"chart_type": "bar",
"data": {
"labels": [],
"datasets": []
},
"metadata": {
"confidence": "insufficient",
"data_points": len(rows) if rows else 0,
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)"
}
}
# Group by ISO week
weekly_data = {}
for row in rows:
date_obj = row['date'] if isinstance(row['date'], datetime) else datetime.fromisoformat(str(row['date']))
iso_week = date_obj.strftime('%Y-W%V')
if iso_week not in weekly_data:
weekly_data[iso_week] = {
'protein': [],
'carbs': [],
'fat': [],
'kcal': []
}
weekly_data[iso_week]['protein'].append(safe_float(row['protein_g']))
weekly_data[iso_week]['carbs'].append(safe_float(row['carbs_g']))
weekly_data[iso_week]['fat'].append(safe_float(row['fat_g']))
weekly_data[iso_week]['kcal'].append(safe_float(row['kcal']))
# Calculate weekly averages and percentages
labels = []
protein_pcts = []
carbs_pcts = []
fat_pcts = []
for iso_week in sorted(weekly_data.keys())[-weeks:]:
data = weekly_data[iso_week]
avg_protein = sum(data['protein']) / len(data['protein']) if data['protein'] else 0
avg_carbs = sum(data['carbs']) / len(data['carbs']) if data['carbs'] else 0
avg_fat = sum(data['fat']) / len(data['fat']) if data['fat'] else 0
# Convert to kcal
protein_kcal = avg_protein * 4
carbs_kcal = avg_carbs * 4
fat_kcal = avg_fat * 9
total_kcal = protein_kcal + carbs_kcal + fat_kcal
if total_kcal > 0:
labels.append(f"KW {iso_week[-2:]}")
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
# Calculate variation coefficient (Variationskoeffizient)
protein_cv = statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 else 0
carbs_cv = statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 else 0
fat_cv = statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 else 0
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Protein (%)",
"data": protein_pcts,
"backgroundColor": "#1D9E75",
"stack": "macro"
},
{
"label": "Kohlenhydrate (%)",
"data": carbs_pcts,
"backgroundColor": "#F59E0B",
"stack": "macro"
},
{
"label": "Fett (%)",
"data": fat_pcts,
"backgroundColor": "#EF4444",
"stack": "macro"
}
]
},
"metadata": {
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
"data_points": len(rows),
"weeks_analyzed": len(labels),
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
"protein_cv": round(protein_cv, 1),
"carbs_cv": round(carbs_cv, 1),
"fat_cv": round(fat_cv, 1)
}
}
return get_weekly_macro_distribution_chart_data(profile_id, weeks)
@router.get("/nutrition-adherence-score")

View File

@ -699,62 +699,50 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
)
}
function buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
}) {
const tiles = [
{
key: 'kcal',
category: 'Kalorien (Ø)',
icon: '🔥',
value: `${avgKcal} kcal`,
sublabel: dateSpanLabel,
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche tägliche Energie',
hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`,
},
{
key: 'carbs',
category: 'KH (Ø)',
icon: '🌾',
value: `${avgCarbs} g`,
sublabel: 'Kohlenhydrate / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliche Kohlenhydrate',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
{
key: 'fat',
category: 'Fett (Ø)',
icon: '🧈',
value: `${avgFat} g`,
sublabel: 'Fett / Tag',
status: 'good',
verdict: '',
hoverTop: 'Durchschnittliches Fett',
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
},
]
macroRules.forEach((r, i) => {
tiles.push({
key: `eval-${i}`,
category: r.category,
icon: r.icon,
value: r.value,
sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}` : r.title,
status: r.status,
verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'),
hoverTop: r.title,
hoverBody: r.detail,
})
})
return tiles
}
/** 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
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 }}>
Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht.
</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={['auto', 'auto']} />
<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="#EA580C" strokeDasharray="6 4" strokeWidth={1.2} />
)}
<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>
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 6 }}>
{tdeeLabel != null
? `Referenz TDEE ~${tdeeLabel} kcal (Data Layer, gestrichelt) · ${n} gemeinsame Tage`
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
</div>
</div>
)
}
/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */
function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) {
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')
@ -794,21 +782,20 @@ function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime })
</LineChart>
</ResponsiveContainer>
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 6 }}>
Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage
Referenz TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
</div>
</div>
)
}
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) {
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(30)
const [groupedGoals, setGroupedGoals] = useState(null)
const chartDays = period === 9999 ? 90 : period
const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7)))
const [weeklyMacro, setWeeklyMacro] = useState(null)
const [wmLoading, setWmLoading] = useState(false)
const [wmError, setWmError] = useState(null)
const [viz, setViz] = useState(null)
const [vizLoad, setVizLoad] = useState(true)
const [vizErr, setVizErr] = useState(null)
useEffect(() => {
let cancelled = false
@ -820,120 +807,110 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
useEffect(() => {
let cancelled = false
setWmLoading(true)
setWmError(null)
api.getWeeklyMacroDistributionChart(weeks)
.then(d => { if (!cancelled) setWeeklyMacro(d) })
.catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') })
.finally(() => { if (!cancelled) setWmLoading(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 }
}, [weeks])
}, [period])
if (!nutrition?.length) {
if (vizLoad) {
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
<PeriodSelector value={period} onChange={setPeriod} />
<div className="spinner" style={{ margin: 24 }} />
</div>
)
}
if (vizErr) {
return (
<div>
<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 (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
)
}
const cutoff = dayjs().subtract(period, 'day').format('YYYY-MM-DD')
const filtN = nutrition.filter(d => period === 9999 || d.date >= cutoff)
const sorted = [...filtN].sort((a, b) => a.date.localeCompare(b.date))
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 kpiTiles = (viz.kpi_tiles || []).map(t => ({
...t,
sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}` : t.sublabel,
}))
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 wmLoading = false
const wmError = null
if (!filtN.length) {
if (!cdMacro.length || n === 0) {
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />
<PeriodSelector value={period} onChange={setPeriod}/>
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
</div>
)
}
const n = filtN.length
const avgKcal = Math.round(filtN.reduce((s, d) => s + (d.kcal || 0), 0) / n)
const avgProtein = Math.round(filtN.reduce((s, d) => s + (d.protein_g || 0), 0) / n * 10) / 10
const avgFat = Math.round(filtN.reduce((s, d) => s + (d.fat_g || 0), 0) / n * 10) / 10
const avgCarbs = Math.round(filtN.reduce((s, d) => s + (d.carbs_g || 0), 0) / n * 10) / 10
const latestW = weights?.[0]?.weight || 80
const ptLow = Math.round(latestW * 1.6)
const ptHigh = Math.round(latestW * 2.2)
const proteinOk = avgProtein >= ptLow
const cdMacro = sorted.map(d => ({
date: fmtDate(d.date),
Protein: Math.round(d.protein_g || 0),
KH: Math.round(d.carbs_g || 0),
Fett: Math.round(d.fat_g || 0),
kcal: Math.round(d.kcal || 0),
}))
const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9
const pieData = [
{ name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' },
{ name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' },
{ name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' },
]
const macroRules = []
if (!proteinOk) {
macroRules.push({
status: 'bad', icon: '🥩', category: 'Protein',
title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail: `1,62,2g/kg KG. Fehlend: ~${Math.max(0, ptLow - Math.round(avgProtein))}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
value: `${avgProtein}g`,
})
} else {
macroRules.push({
status: 'good', icon: '🥩', category: 'Protein',
title: `Gut: ${avgProtein}g/Tag (Ziel ${ptLow}${ptHigh}g)`,
detail: 'Ausreichend für Muskelerhalt und -aufbau.',
value: `${avgProtein}g`,
})
}
const protPct = Math.round(avgProtein * 4 / totalMacroKcal * 100)
if (protPct < 20) {
macroRules.push({
status: 'warn', icon: '📊', category: 'Makro-Anteil',
title: `Protein-Anteil niedrig: ${protPct}% der Kalorien`,
detail: `Empfehlung oft 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`,
value: `${protPct}%`,
})
}
const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}`
const kpiTiles = buildNutritionKpiTiles({
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
})
return (
<div>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
<strong>Kalorien vs. Gewicht</strong> bezieht gemeinsame Tage aus Ernährung und Gewicht.
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
</p>
<NutritionGoalsStrip grouped={groupedGoals} />
<KpiTilesOverview tiles={kpiTiles} />
<KcalVsWeightChart corrData={corrData} profile={profile} cutoffDate={cutoff} allTime={period === 9999} />
<KcalVsWeightChart
vizKcalWeight={viz.kcal_vs_weight}
corrData={[]}
profile={profile}
cutoffDate=""
allTime={period === 9999}
/>
<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).
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<ResponsiveContainer width="100%" height={200}>
<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} />
<ReferenceLine y={ptLow} stroke="#059669" strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: '#059669', position: 'insideTopRight' }} />
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke="#059669" strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: '#059669', position: 'insideTopRight' }} />
)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Fett" stackId="a" fill="#93C5FD" name="Fett" />
<Bar dataKey="KH" stackId="a" fill="#FDBA74" name="KH" />
@ -953,27 +930,33 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
Ø Makro-Quote ({n} Tage)
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<PieChart width={120} height={120}>
<Pie data={pieData} cx={58} cy={58} innerRadius={36} outerRadius={54} dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e, i) => <Cell key={i} fill={e.color} />)}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
<div style={{ flex: 1, minWidth: 160 }}>
{pieData.map(p => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: p.color, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: p.color }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{Math.round(p.name === 'Protein' ? avgProtein : p.name === 'KH' ? avgCarbs : avgFat)}g
{pieData.length > 0 ? (
<>
<PieChart width={120} height={120}>
<Pie data={pieData} cx={58} cy={58} innerRadius={36} outerRadius={54} dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e, i) => <Cell key={i} fill={e.color} />)}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
<div style={{ flex: 1, minWidth: 160 }}>
{pieData.map(p => (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: p.color, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: p.color }}>{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 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 style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
</div>
<div className="card nutrition-macro-pair__weekly">
@ -1501,7 +1484,7 @@ export default function History() {
</nav>
<div className="history-content">
{tab==='body' && <BodySection profile={profile} {...sp}/>}
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} corrData={corrData} {...sp}/>}
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
{tab==='recovery' && <RecoverySection {...sp}/>}
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}

View File

@ -637,6 +637,8 @@ export const api = {
// Nutrition Charts (E1-E5)
/** Layer 2b: Verlauf Körper — Charts, Kennzahlen, Bewertung (einheitlich mit Platzhalter-Registry) */
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
/** Layer 2b: Verlauf Ernährung — Kennzahlen, Reihen, TDEE, Wochen-Chart (nutrition_metrics) */
getNutritionHistoryViz: (days=90) => req(`/charts/nutrition-history-viz?days=${days}`),
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?days=${days}`),