Merge pull request 'Ernährungsasuwertungen' (#94) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 58s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 18s

Reviewed-on: #94
This commit is contained in:
Lars 2026-04-19 21:02:28 +02:00
commit aa6644f44b
12 changed files with 1776 additions and 602 deletions

View File

@ -0,0 +1,219 @@
"""
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"),
"hint": "Ohne aktuelles Körpergewicht lässt sich das Protein-Ziel (g/kg) nicht bewerten.",
"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"),
"hint": (
f"~{miss} g Protein/Tag fehlen bei Defizit Muskelerhalt gefährdet."
),
"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"),
"hint": (
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
"Ziel oft 2535 %."
),
"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_energy_availability_kpi_tile(ea: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""E5: nur bei caution/warning — gleiche Daten wie /charts/energy-availability-warning."""
level = str(ea.get("warning_level") or "none").strip().lower()
if level == "none":
return None
triggers: List[str] = list(ea.get("triggers") or [])
msg = str(ea.get("message") or "").strip()
st = "bad" if level == "warning" else "warn"
first = triggers[0] if triggers else msg
if len(first) > 90:
first = first[:87] + ""
meta = ea.get("metadata") if isinstance(ea.get("metadata"), dict) else {}
note = str(meta.get("note") or "")
hover_lines = [msg] + [f"{t}" for t in triggers]
if note:
hover_lines.append(note)
return {
"key": "energy-availability-e5",
"category": "Energieverfügbarkeit",
"icon": "",
"value": "Achtung" if level == "warning" else "Hinweis",
"sublabel": first or "Signale prüfen",
"status": st,
"verdict": _verdict(st),
"hint": msg,
"hoverTop": "Energieverfügbarkeit (Heuristik)",
"hoverBody": "\n".join(hover_lines),
"keys": ["nutrition_score"],
}
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": "#4a8f72", "grams": round(p, 1)},
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#c17d45", "grams": round(c, 1)},
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#6e8eb8", "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,200 @@ 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": "#4a8f72",
"stack": "macro",
},
{
"label": "Kohlenhydrate (%)",
"data": carbs_pcts,
"backgroundColor": "#c17d45",
"stack": "macro",
},
{
"label": "Fett (%)",
"data": fat_pcts,
"backgroundColor": "#6e8eb8",
"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),
},
}
def get_energy_availability_warning_payload(profile_id: str, days: int = 14) -> Dict:
"""
E5 Energieverfügbarkeit gleiche Heuristik wie GET /charts/energy-availability-warning.
"""
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
from data_layer.body_metrics import calculate_lbm_28d_change
triggers: List[str] = []
warning_level = "none"
energy_data = get_energy_balance_data(profile_id, days)
if energy_data.get("energy_balance", 0) < -500:
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
try:
recovery_score = calculate_recovery_score_v2(profile_id)
if recovery_score and recovery_score < 50:
triggers.append("Recovery Score niedrig (<50)")
except Exception:
pass
try:
sleep_quality = calculate_sleep_quality_7d(profile_id)
if sleep_quality and sleep_quality < 60:
triggers.append("Schlafqualität reduziert (<60%)")
except Exception:
pass
try:
lbm_change = calculate_lbm_28d_change(profile_id)
if lbm_change and lbm_change < -1.0:
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
except Exception:
pass
if len(triggers) >= 3:
warning_level = "warning"
message = (
"⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. "
"Erwäge Defizit-Anpassung oder Regenerationswoche."
)
elif len(triggers) >= 2:
warning_level = "caution"
message = (
"⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
)
elif len(triggers) >= 1:
warning_level = "caution"
message = "💡 Ein Indikator auffällig. Weiter beobachten."
else:
message = "✅ Energieverfügbarkeit unauffällig."
return {
"warning_level": warning_level,
"triggers": triggers,
"message": message,
"metadata": {
"days_analyzed": days,
"trigger_count": len(triggers),
"note": "Heuristische Einschätzung, keine medizinische Diagnose",
},
}
# ============================================================================
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
# ============================================================================

View File

@ -0,0 +1,294 @@
"""
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_energy_availability_kpi_tile,
build_macro_donut_from_averages,
build_nutrition_history_kpi_tiles,
)
from data_layer.nutrition_metrics import (
estimate_tdee_kcal_from_latest_weight,
get_energy_availability_warning_payload,
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": [],
"energy_availability_warning": None,
"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)
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
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)
)
ea_days = min(28, max(7, chart_days_for_pipeline))
ea_payload = get_energy_availability_warning_payload(profile_id, ea_days)
ea_tile = build_energy_availability_kpi_tile(ea_payload)
kpi_tiles_out: List[Dict[str, Any]] = list(kpi_tiles)
if ea_tile:
kpi_tiles_out.append(ea_tile)
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))
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_out,
"interpretation_tiles": [],
"energy_availability_warning": ea_payload,
"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,15 @@ 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,
get_energy_availability_warning_payload,
)
from data_layer.activity_metrics import (
get_activity_summary_data,
@ -265,6 +268,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 +853,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")
@ -1130,87 +1027,10 @@ def get_energy_availability_warning(
"""
Energy Availability Warning (E5) - Konzept-konform.
Heuristic warning for potential undernutrition/overtraining.
Checks:
- Persistent large deficit
- Recovery score declining
- Sleep quality declining
- LBM declining
Args:
days: Analysis window (7-28 days, default 14)
session: Auth session (injected)
Returns:
{
"warning_level": "none" | "caution" | "warning",
"triggers": [...],
"message": "..."
}
Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload
"""
profile_id = session['profile_id']
from db import get_db, get_cursor
from data_layer.nutrition_metrics import get_energy_balance_data
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
from data_layer.body_metrics import calculate_lbm_28d_change
triggers = []
warning_level = "none"
# Check 1: Large energy deficit
energy_data = get_energy_balance_data(profile_id, days)
if energy_data.get('energy_balance', 0) < -500:
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
# Check 2: Recovery declining
try:
recovery_score = calculate_recovery_score_v2(profile_id)
if recovery_score and recovery_score < 50:
triggers.append("Recovery Score niedrig (<50)")
except:
pass
# Check 3: Sleep quality
try:
sleep_quality = calculate_sleep_quality_7d(profile_id)
if sleep_quality and sleep_quality < 60:
triggers.append("Schlafqualität reduziert (<60%)")
except:
pass
# Check 4: LBM declining
try:
lbm_change = calculate_lbm_28d_change(profile_id)
if lbm_change and lbm_change < -1.0:
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
except:
pass
# Determine warning level
if len(triggers) >= 3:
warning_level = "warning"
message = "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. Erwäge Defizit-Anpassung oder Regenerationswoche."
elif len(triggers) >= 2:
warning_level = "caution"
message = "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
elif len(triggers) >= 1:
warning_level = "caution"
message = "💡 Ein Indikator auffällig. Weiter beobachten."
else:
message = "✅ Energieverfügbarkeit unauffällig."
return {
"warning_level": warning_level,
"triggers": triggers,
"message": message,
"metadata": {
"days_analyzed": days,
"trigger_count": len(triggers),
"note": "Heuristische Einschätzung, keine medizinische Diagnose"
}
}
return get_energy_availability_warning_payload(profile_id, days)
@router.get("/training-volume")

View File

@ -199,13 +199,16 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
/* Körper-Verlauf: KPI-Übersicht (Hover = Details, kein Klick) */
/* KPI-Kachel-Raster: gemeinsam für Verlauf Körper, Dashboard KPI-Board,
Desktop: title-Tooltip; Touch: Bottom-Sheet (siehe KpiTilesOverview.jsx) */
.kpi-tiles-grid,
.body-kpi-overview {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.kpi-tiles-card,
.body-kpi-card {
background: var(--surface2);
border-radius: 10px;
@ -215,11 +218,190 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
text-align: left;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@media (hover: none) {
.kpi-tiles-card,
.body-kpi-card {
cursor: default;
}
}
.kpi-tiles-card:hover,
.body-kpi-card:hover {
border-color: var(--border2);
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
}
.kpi-tiles-info-btn,
.body-kpi-info-btn {
position: absolute;
top: 6px;
right: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
margin: 0;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text3);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kpi-tiles-info-btn:active,
.body-kpi-info-btn:active {
background: var(--surface);
color: var(--accent);
}
.kpi-tiles-touch-backdrop,
.body-kpi-touch-backdrop {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
background: rgba(0, 0, 0, 0.45);
animation: kpi-tiles-fade-in 0.15s ease;
}
@keyframes kpi-tiles-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes body-kpi-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.kpi-tiles-touch-sheet,
.body-kpi-touch-sheet {
width: 100%;
max-width: 520px;
max-height: min(72vh, 560px);
overflow: auto;
margin: 0 auto;
padding: 14px 16px 18px;
border-radius: 16px 16px 0 0;
background: var(--surface);
border: 1px solid var(--border);
border-bottom: none;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.18);
}
.kpi-tiles-touch-sheet__head,
.body-kpi-touch-sheet__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.kpi-tiles-touch-sheet__title,
.body-kpi-touch-sheet__title {
margin: 0;
font-size: 16px;
font-weight: 700;
color: var(--text1);
line-height: 1.3;
flex: 1;
min-width: 0;
}
.kpi-tiles-touch-sheet__close,
.body-kpi-touch-sheet__close {
flex-shrink: 0;
width: 40px;
height: 40px;
margin: -6px -8px 0 0;
padding: 0;
border: none;
border-radius: 10px;
background: transparent;
color: var(--text2);
font-size: 26px;
line-height: 1;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.kpi-tiles-touch-sheet__close:active,
.body-kpi-touch-sheet__close:active {
background: var(--surface2);
}
.kpi-tiles-touch-sheet__body,
.body-kpi-touch-sheet__body {
font-size: 13px;
line-height: 1.5;
color: var(--text2);
white-space: pre-wrap;
word-break: break-word;
}
.kpi-tiles-touch-sheet__body--muted,
.body-kpi-touch-sheet__body--muted {
color: var(--text3);
font-style: italic;
}
/* KPI: Kurz-Hinweis max. 2 Zeilen — Details weiter per */
.kpi-tiles-card__hint {
max-height: 2.8em;
}
/* Verlauf Ernährung: Donut (Ø-Quote) + wöchentliche Makro-Verteilung (E3) */
.nutrition-macro-pair {
display: grid;
gap: 12px;
margin-bottom: 12px;
align-items: stretch;
}
@media (min-width: 780px) {
.nutrition-macro-pair {
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1.25fr);
}
}
.nutrition-macro-pair__weekly {
min-width: 0;
}
/* Einheitliche Chart-Höhe (Donut-Bereich ≈ E3-Balken) */
.nutrition-macro-pair__chart-wrap {
width: 100%;
min-height: 260px;
}
.nutrition-macro-pair__donut-inner {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.nutrition-macro-pair__donut-chart {
width: 100%;
min-height: 260px;
}
.nutrition-macro-pair__legend {
width: 100%;
padding-top: 2px;
}
.nutrition-macro-pair .card.nutrition-macro-pair__donut,
.nutrition-macro-pair .card.nutrition-macro-pair__weekly {
display: flex;
flex-direction: column;
}
.history-page__title {
margin-bottom: 12px;
}

View File

@ -0,0 +1,178 @@
import { useState, useEffect, useId } from 'react'
import { Info } from 'lucide-react'
import { getStatusColor } from '../utils/interpret'
/**
* Zerlegt eine KPI-Kachel für Bottom-Sheet / Tooltip.
* @param {{ hoverTop?: string, category?: string, hoverBody?: string, keys?: string[] }} t
*/
export function kpiTileDetailParts(t) {
const registryLine = t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''
const body = [t.hoverBody, registryLine].filter(Boolean).join('\n\n')
return { title: t.hoverTop || t.category || 'Kennzahl', body }
}
/** Ein Zeilentext wie natives `title` (Desktop-Hover). */
export function buildKpiTileTitleString(t) {
return [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : '']
.filter(Boolean)
.join('\n\n')
}
/**
* Standard-KPI-Kacheln: Desktop `title`-Tooltip, Touch Bottom-Sheet (gleicher Inhalt).
*
* Erwartete Kachel-Felder:
* - `key` (string, eindeutig)
* - `category` (string) Zeilenkopf
* - `value` (ReactNode) Hauptwert
* - `status` für Farbstreifen: `good` | `warn` | `bad`
* - optional: `icon`, `sublabel`, `verdict`, `valueColor`, `hoverTop`, `hoverBody`, `keys`
* - optional: `hint` Kurz-Hinweis/Warnung direkt auf der Kachel (z. B. Ernährung bei warn/bad)
*/
export default function KpiTilesOverview({
tiles,
heading = 'Kennzahlen',
showTouchHint = true,
gridClassName = 'kpi-tiles-grid',
marginBottom = 12,
}) {
const [touchUi, setTouchUi] = useState(false)
const [openKey, setOpenKey] = useState(null)
const sheetTitleId = useId()
useEffect(() => {
const mq = window.matchMedia('(hover: none)')
const apply = () => setTouchUi(mq.matches)
apply()
mq.addEventListener('change', apply)
return () => mq.removeEventListener('change', apply)
}, [])
useEffect(() => {
if (!openKey) return
const onKey = e => { if (e.key === 'Escape') setOpenKey(null) }
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', onKey)
return () => {
document.body.style.overflow = prev
window.removeEventListener('keydown', onKey)
}
}, [openKey])
if (!tiles?.length) return null
const openTile = openKey ? tiles.find(x => x.key === openKey) : null
const openParts = openTile ? kpiTileDetailParts(openTile) : null
const showVerdict = (v) => v != null && String(v).trim() !== '' && String(v).trim() !== '—'
return (
<div style={{ marginBottom }}>
{heading ? (
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>{heading}</div>
) : null}
{showTouchHint && touchUi && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 8, lineHeight: 1.4 }}>
<Info size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: 4 }} aria-hidden />
Auf dem Smartphone: <strong></strong> für Erklärung und Details.
</div>
)}
<div className={gridClassName}>
{tiles.map(t => {
const accent = getStatusColor(t.status)
const tip = buildKpiTileTitleString(t)
const cardHint = t.hint ? String(t.hint) : null
return (
<div
key={t.key}
className="kpi-tiles-card"
style={{ borderLeft: `4px solid ${accent}`, position: 'relative' }}
title={touchUi ? undefined : tip}
>
{touchUi && (
<button
type="button"
className="kpi-tiles-info-btn"
aria-label={`Details: ${t.category || t.hoverTop || 'Kennzahl'}`}
aria-expanded={openKey === t.key}
onClick={() => setOpenKey(k => (k === t.key ? null : t.key))}
>
<Info size={16} strokeWidth={2.25} aria-hidden />
</button>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6, paddingRight: touchUi ? 28 : 0 }}>
{t.icon != null && t.icon !== false ? (
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
) : (
<span style={{ width: 0, flexShrink: 0 }} aria-hidden />
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
{t.sublabel ? (
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
) : null}
</div>
{showVerdict(t.verdict) ? (
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
</div>
) : null}
</div>
{cardHint ? (
<div
className="kpi-tiles-card__hint"
style={{
marginTop: 6,
paddingLeft: 8,
borderLeft: `2px solid ${accent}`,
fontSize: 9,
lineHeight: 1.35,
color: 'var(--text2)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
wordBreak: 'break-word',
}}
>
{cardHint}
</div>
) : null}
</div>
)
})}
</div>
{openParts && (
<div
className="kpi-tiles-touch-backdrop"
role="presentation"
onClick={() => setOpenKey(null)}
>
<div
className="kpi-tiles-touch-sheet"
role="dialog"
aria-modal="true"
aria-labelledby={sheetTitleId}
onClick={e => e.stopPropagation()}
>
<div className="kpi-tiles-touch-sheet__head">
<h3 id={sheetTitleId} className="kpi-tiles-touch-sheet__title">{openParts.title}</h3>
<button type="button" className="kpi-tiles-touch-sheet__close" onClick={() => setOpenKey(null)} aria-label="Schließen">
×
</button>
</div>
{openParts.body ? (
<div className="kpi-tiles-touch-sheet__body">{openParts.body}</div>
) : (
<div className="kpi-tiles-touch-sheet__body kpi-tiles-touch-sheet__body--muted">Keine weiteren Details.</div>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react'
import {
LineChart, Line, BarChart, Bar,
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
ComposedChart, ReferenceArea,
} from 'recharts'
import { api } from '../utils/api'
import { MACRO_CHART, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
import dayjs from 'dayjs'
const fmtDate = d => dayjs(d).format('DD.MM')
@ -135,16 +137,81 @@ function WarningCard({ title, warning_level, triggers, message }) {
)
}
/** Wöchentliche Makro-Verteilung (E3) — für Verlauf neben Donut nutzbar. */
export function WeeklyMacroDistributionPanel({ macroWeeklyData, loading, error }) {
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 40 }}>
<div className="spinner" style={{ width: 32, height: 32 }} />
</div>
)
}
if (error) {
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{error}</div>
)
}
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
const msg = macroWeeklyData?.metadata?.message || 'Nicht genug Daten für Wochen-Analyse (min. 7 Tage)'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
)
}
const chartData = macroWeeklyData.data.labels.map((label, i) => ({
week: label,
protein: macroWeeklyData.data.datasets[0]?.data[i],
carbs: macroWeeklyData.data.datasets[1]?.data[i],
fat: macroWeeklyData.data.datasets[2]?.data[i],
}))
const meta = macroWeeklyData.metadata
return (
<>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Anteil der Kalorien aus jedem Makronährstoff pro Kalenderwoche (100&nbsp;% gestapelt). Gut vergleichbar mit der
Donut-Übersicht links.
</div>
<div className="nutrition-macro-pair__chart-wrap">
<ResponsiveContainer width="100%" height={NUTRITION_MACRO_CHART_BLOCK_PX}>
<BarChart data={chartData} margin={{ top: 8, right: 4, bottom: 4, left: -18 }} barCategoryGap="18%">
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
<XAxis
dataKey="week"
tick={{ fontSize: 9, fill: 'var(--text3)' }}
tickLine={false}
interval={Math.max(0, Math.floor(chartData.length / 8) - 1)}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={[0, 100]} ticks={[0, 25, 50, 75, 100]} />
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
formatter={(v, name) => [`${v}%`, name]}
/>
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 8 }} />
<Bar dataKey="protein" stackId="a" fill={MACRO_CHART.protein} name="Protein %" radius={[0, 0, 0, 0]} />
<Bar dataKey="fat" stackId="a" fill={MACRO_CHART.fat} name="Fett %" radius={[0, 0, 0, 0]} />
<Bar dataKey="carbs" stackId="a" fill={MACRO_CHART.carbs} name="KH %" radius={[6, 6, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div style={{ marginTop: 10, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
Ø Verteilung: P {meta.avg_protein_pct}% · KH {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% · Variabilität (CV): P{' '}
{meta.protein_cv}% · KH {meta.carbs_cv}% · F {meta.fat_cv}%
</div>
</>
)
}
/**
* Nutrition Charts Component (E1-E5) - Konzept-konform v2.0
*
* E1: Energy Balance (mit 7d/14d Durchschnitten)
* E2: Protein Adequacy (mit 7d/28d Durchschnitten)
* E3: Weekly Macro Distribution (100% gestapelte Balken)
* E4: Nutrition Adherence Score (0-100, goal-aware)
* E5: Energy Availability Warning (Ampel-System)
* Nutrition Charts (E1E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z.B. neben Donut) gerendert wird.
*/
export default function NutritionCharts({ days = 28 }) {
export default function NutritionCharts({
days = 28,
showWeeklyMacroDistribution = true,
/** Verlauf: E5-Kachel liegt in nutrition-history-viz KPIs — doppelte Karte ausblenden */
hideEnergyAvailabilityCard = false,
}) {
const [energyData, setEnergyData] = useState(null)
const [proteinData, setProteinData] = useState(null)
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
@ -159,16 +226,21 @@ export default function NutritionCharts({ days = 28 }) {
useEffect(() => {
loadCharts()
}, [days])
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard])
const loadCharts = async () => {
await Promise.all([
const tasks = [
loadEnergyBalance(),
loadProteinAdequacy(),
loadMacroWeekly(),
loadAdherence(),
loadWarning()
])
]
if (!hideEnergyAvailabilityCard) {
tasks.push(loadWarning())
}
if (showWeeklyMacroDistribution) {
tasks.splice(2, 0, loadMacroWeekly())
}
await Promise.all(tasks)
}
const loadEnergyBalance = async () => {
@ -236,12 +308,13 @@ export default function NutritionCharts({ days = 28 }) {
}
}
// E1: Energy Balance Timeline (mit 7d/14d Durchschnitten)
// E1: Energy Balance klare Farben (kein hellgraues Gewirr)
const renderEnergyBalance = () => {
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Ernährungsdaten (min. 7 Tage)
</div>
const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
)
}
const chartData = energyData.data.labels.map((label, i) => ({
@ -249,7 +322,7 @@ export default function NutritionCharts({ days = 28 }) {
täglich: energyData.data.datasets[0]?.data[i],
avg7d: energyData.data.datasets[1]?.data[i],
avg14d: energyData.data.datasets[2]?.data[i],
tdee: energyData.data.datasets[3]?.data[i]
tdee: energyData.data.datasets[3]?.data[i],
}))
const balance = energyData.metadata?.energy_balance || 0
@ -257,111 +330,90 @@ export default function NutritionCharts({ days = 28 }) {
return (
<>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE Linien sind farblich getrennt (Legende unten).
</div>
<ResponsiveContainer width="100%" height={240}>
<LineChart data={chartData} margin={{ top: 6, right: 10, bottom: 4, 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(chartData.length / 6) - 1)}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
<Legend wrapperStyle={{fontSize:10}}/>
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
<Line type="monotone" dataKey="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/>
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/>
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
/>
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 6 }} />
<Line type="monotone" dataKey="täglich" stroke="#64748B" strokeWidth={2} dot={{ r: 2 }} name="Täglich kcal" />
<Line type="monotone" dataKey="avg14d" stroke="#6366F1" strokeWidth={2.5} dot={false} name="Ø 14 Tage" />
<Line type="monotone" dataKey="avg7d" stroke="#10B981" strokeWidth={3} dot={false} name="Ø 7 Tage" />
<Line type="monotone" dataKey="tdee" stroke="#EA580C" strokeWidth={2.5} strokeDasharray="10 5" dot={false} name="TDEE (Referenz)" />
</LineChart>
</ResponsiveContainer>
<div style={{marginTop:8,fontSize:10,textAlign:'center'}}>
<span style={{color:'var(--text3)'}}>
Ø {energyData.metadata.avg_kcal} kcal/Tag ·
</span>
<div style={{ marginTop: 8, fontSize: 10, textAlign: 'center', lineHeight: 1.5 }}>
<span style={{ color: 'var(--text3)' }}>Ø {energyData.metadata.avg_kcal} kcal/Tag ·</span>
<span style={{ color: balanceColor, fontWeight: 600, marginLeft: 4 }}>
Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag
</span>
<span style={{color:'var(--text3)',marginLeft:8}}>
· {energyData.metadata.data_points} Tage
Balance: {balance > 0 ? '+' : ''}
{balance} kcal/Tag
</span>
<span style={{ color: 'var(--text3)', marginLeft: 8 }}>· {energyData.metadata.data_points} Tage</span>
</div>
</>
)
}
// E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten)
// E2: Protein Zielzone als Fläche, Linien klar von E1 abgrenzbar
const renderProteinAdequacy = () => {
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Protein-Daten (min. 7 Tage)
</div>
const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.'
return (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
)
}
const tl = proteinData.metadata.target_low
const th = proteinData.metadata.target_high
const chartData = proteinData.data.labels.map((label, i) => ({
date: fmtDate(label),
täglich: proteinData.data.datasets[0]?.data[i],
avg7d: proteinData.data.datasets[1]?.data[i],
avg28d: proteinData.data.datasets[2]?.data[i],
targetLow: proteinData.data.datasets[3]?.data[i],
targetHigh: proteinData.data.datasets[4]?.data[i]
}))
return (
<>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel andere Farben als Energiebilanz oben.
</div>
<ResponsiveContainer width="100%" height={250}>
<ComposedChart data={chartData} margin={{ top: 6, right: 10, bottom: 4, 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(chartData.length / 6) - 1)}
/>
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
<Legend wrapperStyle={{fontSize:10}}/>
<Line type="monotone" dataKey="targetLow" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Min"/>
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Max"/>
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
<Line type="monotone" dataKey="avg28d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 28d"/>
</LineChart>
<Tooltip
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
/>
{tl != null && th != null && (
<ReferenceArea y1={tl} y2={th} fill="rgba(16, 185, 129, 0.14)" stroke="#10B981" strokeOpacity={0.35} />
)}
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 6 }} />
<Line type="monotone" dataKey="avg28d" stroke="#7C3AED" strokeWidth={2.5} dot={false} name="Ø 28 Tage" />
<Line type="monotone" dataKey="avg7d" stroke="#059669" strokeWidth={3} dot={false} name="Ø 7 Tage" />
<Line type="monotone" dataKey="täglich" stroke="#0284C7" strokeWidth={2} dot={{ r: 2 }} name="Täglich g" />
</ComposedChart>
</ResponsiveContainer>
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
{proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%)
</div>
</>
)
}
// E3: Weekly Macro Distribution (100% gestapelte Balken)
const renderMacroWeekly = () => {
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
Nicht genug Daten für Wochen-Analyse (min. 7 Tage)
</div>
}
const chartData = macroWeeklyData.data.labels.map((label, i) => ({
week: label,
protein: macroWeeklyData.data.datasets[0]?.data[i],
carbs: macroWeeklyData.data.datasets[1]?.data[i],
fat: macroWeeklyData.data.datasets[2]?.data[i]
}))
const meta = macroWeeklyData.metadata
return (
<>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="week" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(chartData.length/8)-1)}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={[0,100]}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
<Legend wrapperStyle={{fontSize:10}}/>
<Bar dataKey="protein" stackId="a" fill="#1D9E75" name="Protein %"/>
<Bar dataKey="carbs" stackId="a" fill="#F59E0B" name="Kohlenhydrate %"/>
<Bar dataKey="fat" stackId="a" fill="#EF4444" name="Fett %"/>
</BarChart>
</ResponsiveContainer>
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
Ø Verteilung: P {meta.avg_protein_pct}% · C {meta.avg_carbs_pct}% · F {meta.avg_fat_pct}% ·
Konsistenz (CV): P {meta.protein_cv}% · C {meta.carbs_cv}% · F {meta.fat_cv}%
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
Ziel {tl}{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich (
{proteinData.metadata.target_compliance_pct}%)
</div>
</>
)
@ -414,20 +466,22 @@ export default function NutritionCharts({ days = 28 }) {
return (
<div>
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}>
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
{renderEnergyBalance()}
</ChartCard>
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}>
<ChartCard title="📊 Protein (Adequacy)" loading={loading.protein} error={errors.protein}>
{renderProteinAdequacy()}
</ChartCard>
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}>
{renderMacroWeekly()}
{showWeeklyMacroDistribution && (
<ChartCard title="📊 Wöchentliche Makro-Verteilung" loading={loading.macro} error={errors.macro}>
<WeeklyMacroDistributionPanel macroWeeklyData={macroWeeklyData} loading={false} error={null} />
</ChartCard>
)}
{!loading.adherence && !errors.adherence && renderAdherence()}
{!loading.warning && !errors.warning && renderWarning()}
{!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
</div>
)
}

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import dayjs from 'dayjs'
import { api } from '../../utils/api'
@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc'
import { useProfile } from '../../context/ProfileContext'
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
import KpiTilesOverview from '../KpiTilesOverview'
const MAX_KPI = 9
@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
return buildAutoTileIds(refTiles, hasBf, hasKcal)
}, [manualOrder, refTiles, bf, avgKcal])
const pushTileForId = useCallback(
(id, out) => {
if (id === 'body_fat') {
if (!bf) return
out.push(
<div key="kpi-bf" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>Körperfett</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: bf.cat?.color || 'var(--text1)' }}>
{bf.pct}%
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>{bf.cat?.label || 'Caliper'}</div>
</div>,
)
return
}
if (id === 'avg_kcal') {
if (avgKcal == null) return
out.push(
<div key="kpi-kcal" className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>
Ø Kalorien ({KPI_KCAL_WINDOW_DEFAULT}T)
</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4, color: '#EF9F27' }}>{avgKcal} kcal</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ernährung</div>
</div>,
)
return
}
const tk = parseRefTypeKey(id)
if (!tk) return
const tile = refByKey.get(tk)
if (!tile?.latest) return
const l = tile.latest
out.push(
<div key={`ref-${tk}`} className="card" style={{ margin: 0, padding: 12, boxSizing: 'border-box' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text3)' }}>{tile.type_label}</div>
<div style={{ fontSize: 17, fontWeight: 700, marginTop: 4 }}>
{formatRefVal(l)}
{l.unit ? (
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text2)', marginLeft: 4 }}>{l.unit}</span>
) : null}
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>Ref.wert</div>
</div>,
)
},
[bf, avgKcal, refByKey],
)
const visibleTiles = useMemo(() => {
const kpiTiles = useMemo(() => {
const out = []
for (const id of orderIds) {
pushTileForId(id, out)
if (id === 'body_fat') {
if (!bf) continue
out.push({
key: 'kpi-bf',
status: 'good',
category: 'Körperfett',
icon: '🫧',
value: `${bf.pct}%`,
sublabel: bf.cat?.label || 'Caliper',
valueColor: bf.cat?.color,
hoverTop: 'Körperfett (Caliper)',
hoverBody:
`Letzte Messung: ${bf.date ? dayjs(bf.date).format('DD.MM.YYYY') : '—'}.\n` +
'Wert aus dem Caliper-Log; die Farbe/Kategorie richtet sich nach Geschlecht und üblicher Spanne.',
})
continue
}
if (id === 'avg_kcal') {
if (avgKcal == null) continue
out.push({
key: 'kpi-kcal',
status: 'good',
category: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T)`,
icon: '🍽️',
value: `${avgKcal} kcal`,
sublabel: 'Ernährung',
valueColor: '#EF9F27',
hoverTop: `Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT} Tage)`,
hoverBody:
`Durchschnitt der täglichen Kalorien aus dem Ernährungs-Log über die letzten ${KPI_KCAL_WINDOW_DEFAULT} Tage (Mittel über alle geladenen Tageseinträge im Fenster).`,
})
continue
}
const tk = parseRefTypeKey(id)
if (!tk) continue
const tile = refByKey.get(tk)
if (!tile?.latest) continue
const l = tile.latest
const valStr = formatRefVal(l)
const withUnit = l.unit ? `${valStr} ${l.unit}`.trim() : valStr
out.push({
key: `ref-${tk}`,
status: 'good',
category: tile.type_label,
icon: '📌',
value: withUnit,
sublabel: 'Ref.wert',
hoverTop: tile.type_label,
hoverBody:
'Persönlicher Referenzwert aus dem Profil. Verwaltung unter Einstellungen → Referenzwerte.',
})
}
return out
}, [orderIds, pushTileForId])
}, [orderIds, bf, avgKcal, refByKey])
if (loading) {
return (
@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
)
}
if (visibleTiles.length === 0) {
if (kpiTiles.length === 0) {
return (
<div className="card section-gap">
<div className="card-title">Kennzahlen</div>
@ -216,7 +218,13 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).'
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
</p>
<div className="ref-value-tiles-grid">{visibleTiles}</div>
<KpiTilesOverview
tiles={kpiTiles}
heading={null}
showTouchHint
gridClassName="ref-value-tiles-grid"
marginBottom={0}
/>
</div>
)
}

View File

@ -11,10 +11,12 @@ 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'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import NutritionCharts from '../components/NutritionCharts'
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
@ -232,39 +234,46 @@ function buildBodyKpiTiles({
return tiles
}
/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */
function BodyKpiOverview({ tiles }) {
if (!tiles?.length) return null
return (
<div style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Kennzahlen</div>
<div className="body-kpi-overview">
{tiles.map(t => {
const accent = getStatusColor(t.status)
const tip = [t.hoverTop, t.hoverBody, t.keys?.length ? `Registry: ${t.keys.join(', ')}` : ''].filter(Boolean).join('\n\n')
function NutritionGoalsStrip({ grouped }) {
const nav = useNavigate()
const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4)
if (!goals.length) return null
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)' }}>Ernährungsbezogene Ziele</div>
<button type="button" className="btn btn-secondary" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => nav('/goals')}>
Ziele <ChevronRight size={10} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{goals.map(g => (
<div
key={t.key}
className="body-kpi-card"
style={{ borderLeft: `4px solid ${accent}` }}
title={tip}
key={g.id}
style={{
flex: '1 1 140px',
background: 'var(--surface2)',
borderRadius: 8,
padding: '8px 10px',
borderTop: `3px solid ${g.is_primary ? 'var(--accent)' : 'var(--border2)'}`,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 6 }}>
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
{t.sublabel && (
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
)}
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
}}>{g.name || g.label_de || g.goal_type}</div>
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
height: '100%',
background: 'var(--accent)',
}} />
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
</div>
</div>
</div>
)
})}
))}
</div>
</div>
)
@ -535,7 +544,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</div>
)}
<BodyKpiOverview tiles={kpiTiles} />
<KpiTilesOverview tiles={kpiTiles} />
{vizLoading && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere</div>
@ -690,191 +699,398 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
</div>
)
}
// Nutrition Section
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
const [period, setPeriod] = useState(30)
if (!nutrition?.length) return (
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
function kcalVsWeightKcalDomain(points, tdeeRef) {
const vals = (points || [])
.map(d => Number(d.kcal_avg))
.filter(v => !Number.isNaN(v))
if (!vals.length) return ['auto', 'auto']
let lo = Math.min(...vals)
let hi = Math.max(...vals)
const t = tdeeRef != null ? Number(tdeeRef) : NaN
if (!Number.isNaN(t)) {
lo = Math.min(lo, t)
hi = Math.max(hi, t)
}
const span = hi - lo || 400
const pad = Math.max(100, span * 0.1)
return [Math.max(0, Math.floor(lo - pad)), Math.ceil(hi + pad)]
}
const TDEE_REF_LINE_COLOR = '#475569'
/** Legende unter dem Chart: Linien + ggf. TDEE-Referenz (gestrichelt). */
function KcalVsWeightLegend({ showTdee }) {
const line = (color) => ({
display: 'inline-block',
width: 22,
height: 3,
background: color,
borderRadius: 1,
verticalAlign: 'middle',
marginRight: 6,
})
return (
<div
className="kcal-vs-weight-legend"
style={{
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: '12px 18px',
marginTop: 10,
fontSize: 10,
color: 'var(--text2)',
lineHeight: 1.35,
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span style={line('#EA580C')} />
Ø Kalorien (7-Tage-Mittel)
</span>
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 9,
height: 9,
borderRadius: '50%',
background: '#2563EB',
marginRight: 6,
verticalAlign: 'middle',
}}
/>
Gewicht (kg)
</span>
{showTdee ? (
<span style={{ display: 'inline-flex', alignItems: 'center' }}>
<span
style={{
display: 'inline-block',
width: 22,
height: 0,
verticalAlign: 'middle',
marginRight: 6,
borderTop: `2px dashed ${TDEE_REF_LINE_COLOR}`,
opacity: 0.95,
}}
/>
TDEE-Referenz (geschätzt)
</span>
) : null}
</div>
)
}
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))
/** 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)
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<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>
</div>
)
}
if (!filtN.length) return (
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)
return (
<div className="card" style={{ marginBottom: 12 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Kalorien (Ø 7 Tage) vs. Gewicht
</div>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Nur Tage mit Kalorien- und Gewichtsdaten. Linke Achse: kcal (Ø 7 Tage), rechte Achse: kg.
</div>
<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={kcalDomainFb} />
<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
/>
<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 />
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 8 }}>
TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
</div>
</div>
)
}
// Nutrition Section
/** 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 [viz, setViz] = useState(null)
const [vizLoad, setVizLoad] = useState(true)
const [vizErr, setVizErr] = useState(null)
useEffect(() => {
let cancelled = false
api.listGoalsGrouped()
.then(g => { if (!cancelled) setGroupedGoals(g) })
.catch(() => { if (!cancelled) setGroupedGoals({}) })
return () => { cancelled = true }
}, [])
useEffect(() => {
let cancelled = false
setViz(null)
setVizLoad(true)
setVizErr(null)
const daysReq = period === 9999 ? 9999 : period
api.getNutritionHistoryViz(daysReq)
.then(v => { if (!cancelled) setViz(v) })
.catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') })
.finally(() => { if (!cancelled) setVizLoad(false) })
return () => { cancelled = true }
}, [period])
if (vizLoad) {
return (
<div>
<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 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 (!cdMacro.length || n === 0) {
return (
<div>
<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
// Stacked macro bar (daily)
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),
}))
// Pie
const totalMacroKcal = avgProtein*4+avgCarbs*4+avgFat*9
const pieData = [
{name:'Protein',value:Math.round(avgProtein*4/totalMacroKcal*100),color:'#1D9E75'},
{name:'KH', value:Math.round(avgCarbs*4/totalMacroKcal*100), color:'#D4537E'},
{name:'Fett', value:Math.round(avgFat*9/totalMacroKcal*100), color:'#378ADD'},
]
// Weekly macro bars
const weeklyMap={}
filtN.forEach(d=>{
const wk=dayjs(d.date).format('YYYY-WW')
const weekNum = (() => { const dt=new Date(d.date); dt.setHours(0,0,0,0); dt.setDate(dt.getDate()+4-(dt.getDay()||7)); const y=new Date(dt.getFullYear(),0,1); return Math.ceil(((dt-y)/86400000+1)/7) })()
if(!weeklyMap[wk]) weeklyMap[wk]={label:'KW'+weekNum,n:0,protein:0,carbs:0,fat:0,kcal:0}
weeklyMap[wk].protein+=d.protein_g||0; weeklyMap[wk].carbs+=d.carbs_g||0
weeklyMap[wk].fat+=d.fat_g||0; weeklyMap[wk].kcal+=d.kcal||0; weeklyMap[wk].n++
})
const weeklyData=Object.values(weeklyMap).slice(-12).map(w=>({
label:w.label,
Protein:Math.round(w.protein/w.n),
KH:Math.round(w.carbs/w.n),
Fett:Math.round(w.fat/w.n),
kcal:Math.round(w.kcal/w.n),
}))
// Rules
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: ~${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: 2535%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs*4/totalMacroKcal*100)}% KH / ${Math.round(avgFat*9/totalMacroKcal*100)}% F`,
value:protPct+'%'})
}
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}/>
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
padding:'8px 6px',textAlign:'center'}}>
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
</div>
))}
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen MifflinSt Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
</p>
<NutritionGoalsStrip grouped={groupedGoals} />
<KpiTilesOverview tiles={kpiTiles} />
<KcalVsWeightChart
vizKcalWeight={viz.kcal_vs_weight}
corrData={[]}
profile={profile}
cutoffDate=""
allTime={period === 9999}
/>
{/* Stacked macro bars (daily) */}
<div className="card" style={{ marginBottom: 12 }}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} {sorted[sorted.length-1]?.date?.slice(0,7)}
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
Makroverteilung täglich (g) · Fokus Protein
</div>
<ResponsiveContainer width="100%" height={170}>
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/>
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
</div>
<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="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[2,2,0,0]}/>
</BarChart>
</ResponsiveContainer>
<div style={{display:'flex',gap:12,justifyContent:'center',marginTop:6,fontSize:10,color:'var(--text3)'}}>
<span><span style={{display:'inline-block',width:10,height:10,background:'#1D9E7599',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Protein</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#D4537E99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>KH</span>
<span><span style={{display:'inline-block',width:10,height:10,background:'#378ADD99',borderRadius:2,verticalAlign:'middle',marginRight:3}}/>Fett</span>
<span><span style={{display:'inline-block',width:14,height:2,background:'#1D9E75',verticalAlign:'middle',marginRight:3,borderTop:'2px dashed #1D9E75'}}/>Protein-Ziel</span>
</div>
</div>
{/* Pie + macro breakdown */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:10}}>
Ø Makroverteilung · {n} Tage ({sorted[0]?.date?.slice(0,10)} {sorted[sorted.length-1]?.date?.slice(0,10)})
</div>
<div style={{display:'flex',alignItems:'center',gap:16}}>
<PieChart width={110} height={110}>
<Pie data={pieData} cx={50} cy={50} innerRadius={32} outerRadius={50}
dataKey="value" startAngle={90} endAngle={-270}>
{pieData.map((e,i)=><Cell key={i} fill={e.color}/>)}
</Pie>
<Tooltip formatter={(v,n)=>[`${v}%`,n]}/>
</PieChart>
<div style={{flex:1}}>
{pieData.map(p=>(
<div key={p.name} style={{display:'flex',alignItems:'center',gap:8,marginBottom:7}}>
<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</div>
{p.name==='Protein' && <div style={{fontSize:10,color:proteinOk?'var(--accent)':'var(--warn)',marginLeft:2}}>
{proteinOk?'✓':'⚠️'} Ziel {ptLow}g
</div>}
</div>
))}
<div style={{marginTop:6,fontSize:11,color:'var(--text3)',borderTop:'1px solid var(--border)',paddingTop:6}}>
Gesamt: {avgKcal} kcal/Tag
</div>
</div>
</div>
</div>
{/* Weekly stacked bars */}
{weeklyData.length>=2 && (
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Makros pro Woche (Ø g/Tag)</div>
<ResponsiveContainer width="100%" height={150}>
<BarChart data={weeklyData} margin={{top:4,right:8,bottom:0,left:-20}}>
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
<XAxis dataKey="label" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}/>
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',
borderRadius:8,fontSize:11}} formatter={(v,n)=>[`${v}g`,n]}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E7599"/>
<Bar dataKey="KH" stackId="a" fill="#D4537E99"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD99" radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
</div>
{ptLow > 0 && (
<ReferenceLine y={ptLow} stroke={MACRO_CHART.protein} strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: MACRO_CHART.protein, position: 'insideTopRight' }} />
)}
<div style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
<Bar dataKey="KH" stackId="a" fill={MACRO_CHART.carbs} name="KH" radius={[5, 5, 0, 0]} />
</BarChart>
</ResponsiveContainer>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8, fontSize: 10, color: 'var(--text3)', flexWrap: 'wrap' }}>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.protein, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Protein (unten)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.fat, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />Fett (Mitte)</span>
<span><span style={{ display: 'inline-block', width: 10, height: 10, background: MACRO_CHART.carbs, borderRadius: 2, verticalAlign: 'middle', marginRight: 4 }} />KH (oben)</span>
</div>
</div>
{/* New Nutrition Charts (Phase 0c) */}
<div style={{marginTop:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
<NutritionCharts days={period === 9999 ? 90 : period} />
<div className="nutrition-macro-pair">
<div className="card nutrition-macro-pair__donut">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
Ø Makro-Quote ({n} Tage)
</div>
{pieData.length > 0 ? (
<div className="nutrition-macro-pair__donut-inner">
<div className="nutrition-macro-pair__donut-chart">
<ResponsiveContainer width="100%" height={NUTRITION_MACRO_CHART_BLOCK_PX}>
<PieChart>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius="38%"
outerRadius="58%"
dataKey="value"
startAngle={90}
endAngle={-270}
paddingAngle={1}
>
{pieData.map((e, i) => (
<Cell key={i} fill={macroFillByName(e.name)} />
))}
</Pie>
<Tooltip formatter={(v, name) => [`${v}%`, name]} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="nutrition-macro-pair__legend">
{pieData.map(p => {
const fill = macroFillByName(p.name)
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: fill, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 13 }}>{p.name}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: fill }}>{p.value}%</div>
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
{p.grams != null ? `${p.grams}g` : '—'}
</div>
</div>
)
})}
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--text3)', borderTop: '1px solid var(--border)', paddingTop: 8 }}>
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
</div>
</div>
</div>
) : (
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
)}
</div>
<div className="card nutrition-macro-pair__weekly">
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 4 }}>
Wöchentliche Makro-Verteilung (Backend)
</div>
<WeeklyMacroDistributionPanel macroWeeklyData={weeklyMacro} loading={wmLoading} error={wmError} />
</div>
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8, marginTop: 4 }}>
Zeitverläufe (Energie & Protein)
</div>
<NutritionCharts days={chartDays} showWeeklyMacroDistribution={false} hideEnergyAvailabilityCard />
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
</div>
@ -1001,10 +1217,7 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
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) // light activity baseline
// Chart 1: Kcal vs Weight
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
// Chart 2: Protein vs Lean Mass (only days with both)
// Protein vs Lean Mass (only days with both)
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
.map(d=>({date:fmtDate(d.date),protein:d.protein_g,lean:d.lean_mass}))
@ -1080,31 +1293,11 @@ function CorrelationSection({ corrData, insights, profile, onRequest, loadingSlu
<div>
<SectionHeader title="🔗 Korrelationen"/>
{/* Chart 1: Kcal vs Weight */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
📉 Kalorien (Ø 7T) vs. Gewicht
</div>
<ResponsiveContainer width="100%" height={190}>
<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']}/>
<ReferenceLine yAxisId="kcal" y={tdee} stroke="var(--text3)" strokeDasharray="3 3" strokeWidth={1}/>
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EF9F27" strokeWidth={2} dot={false} name="kcal_avg"/>
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#378ADD" strokeWidth={2.5} dot={{r:2,fill:'#378ADD'}} name="weight"/>
</LineChart>
</ResponsiveContainer>
<div style={{fontSize:10,color:'var(--text3)',textAlign:'center',marginTop:4}}>
Gestrichelt: geschätzter TDEE {tdee} kcal · <span style={{color:'#EF9F27'}}> Kalorien</span> · <span style={{color:'#378ADD'}}> Gewicht</span>
</div>
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf Ernährung</strong> (gleiche Datenbasis).
</p>
{/* Chart 2: Calorie balance */}
{/* Chart: Calorie balance */}
<div className="card" style={{marginBottom:12}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
Kalorienbilanz (Aufnahme TDEE {tdee} kcal)
@ -1411,7 +1604,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} {...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

@ -5,6 +5,7 @@ import {
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
} from 'recharts'
import { api as nutritionApi } from '../utils/api'
import { MACRO_CHART } from '../utils/macroChartTheme'
import dayjs from 'dayjs'
import isoWeek from 'dayjs/plugin/isoWeek'
dayjs.extend(isoWeek)
@ -709,9 +710,9 @@ function WeeklyMacros({ weekly }) {
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
formatter={(v,n)=>[`${Math.round(v)} g`, n]}/>
<Legend wrapperStyle={{fontSize:11}}/>
<Bar dataKey="Protein" stackId="a" fill="#1D9E75"/>
<Bar dataKey="Fett" stackId="a" fill="#378ADD"/>
<Bar dataKey="Kohlenhydrate" stackId="a" fill="#D4537E" radius={[3,3,0,0]}/>
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein}/>
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat}/>
<Bar dataKey="Kohlenhydrate" stackId="a" fill={MACRO_CHART.carbs} radius={[3,3,0,0]}/>
</BarChart>
</ResponsiveContainer>
)

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}`),

View File

@ -0,0 +1,21 @@
/**
* Einheitliche Makro-Farben für Verlauf (Balken, Donut, E3).
* Reihenfolge gestapelter Balken (Recharts, unten zuerst): Protein Fett Kohlenhydrate.
*/
export const MACRO_CHART = {
protein: '#4a8f72',
fat: '#6e8eb8',
carbs: '#c17d45',
}
/** Einheitliche Höhe Donut-Bereich / E3-Balken (Verlauf) */
export const NUTRITION_MACRO_CHART_BLOCK_PX = 260
/** Farbe nach Segment-Name (Protein / KH / Fett / englische Keys). */
export function macroFillByName(name) {
const n = String(name || '').toLowerCase()
if (n.includes('protein') || n === 'p') return MACRO_CHART.protein
if (n.includes('fett') || n.includes('fat')) return MACRO_CHART.fat
if (n.includes('kh') || n.includes('kohlenhydrat') || n.includes('carb')) return MACRO_CHART.carbs
return MACRO_CHART.carbs
}