feat: implement nutrition history visualization bundle and related API endpoint
- Added a new `nutrition_interpretation.py` file to handle KPI tile generation for nutrition history. - Introduced `nutrition_viz.py` to create a visualization bundle for nutrition data, integrating metrics and historical analysis. - Implemented `get_nutrition_history_viz` endpoint in `charts.py` to serve the new visualization data. - Updated frontend components to fetch and display nutrition history data, enhancing user experience with detailed insights. - Refactored existing logic to streamline data handling and improve overall performance.
This commit is contained in:
parent
a8eafa8ba4
commit
b96b1931db
180
backend/data_layer/nutrition_interpretation.py
Normal file
180
backend/data_layer/nutrition_interpretation.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Interpretation + KPI-Kacheln für Layer 2b Ernährungs-Verlauf.
|
||||
|
||||
Gleiche Schwellen wie zuvor im Frontend (History.jsx); Ausgabe strukturiert
|
||||
für KpiTilesOverview (keys = related_placeholder_keys).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def _verdict(status: str) -> str:
|
||||
if status == "good":
|
||||
return "Gut"
|
||||
if status == "warn":
|
||||
return "Hinweis"
|
||||
return "Achtung"
|
||||
|
||||
|
||||
def build_nutrition_history_kpi_tiles(
|
||||
navg: Dict[str, Any],
|
||||
targets: Dict[str, Any],
|
||||
date_span_label: str,
|
||||
n_days_with_entries: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
KPI-Kacheln wie buildNutritionKpiTiles im Frontend (Kalorien/KH/Fett + Regeln).
|
||||
"""
|
||||
kcal_avg = round(float(navg.get("kcal_avg") or 0))
|
||||
avg_carbs = round(float(navg.get("carbs_avg") or 0) * 10) / 10
|
||||
avg_fat = round(float(navg.get("fat_avg") or 0) * 10) / 10
|
||||
avg_protein = round(float(navg.get("protein_avg") or 0) * 10) / 10
|
||||
|
||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
||||
pt_high = round(float(targets.get("protein_target_high") or 0))
|
||||
targets_ok = targets.get("confidence") != "insufficient" and pt_low > 0
|
||||
protein_ok = targets_ok and avg_protein >= pt_low
|
||||
|
||||
total_macro_kcal = avg_protein * 4 + avg_carbs * 4 + avg_fat * 9
|
||||
prot_pct = (
|
||||
round(avg_protein * 4 / total_macro_kcal * 100)
|
||||
if total_macro_kcal > 0
|
||||
else 0
|
||||
)
|
||||
kh_pct = (
|
||||
round(avg_carbs * 4 / total_macro_kcal * 100)
|
||||
if total_macro_kcal > 0
|
||||
else 0
|
||||
)
|
||||
fat_pct = (
|
||||
round(avg_fat * 9 / total_macro_kcal * 100)
|
||||
if total_macro_kcal > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
tiles: List[Dict[str, Any]] = [
|
||||
{
|
||||
"key": "kcal",
|
||||
"category": "Kalorien (Ø)",
|
||||
"icon": "🔥",
|
||||
"value": f"{kcal_avg} kcal",
|
||||
"sublabel": date_span_label,
|
||||
"status": "good",
|
||||
"verdict": "Gut",
|
||||
"hoverTop": "Durchschnittliche tägliche Energie",
|
||||
"hoverBody": f"Mittel über {n_days_with_entries} Tage mit Ernährungseinträgen im gewählten Zeitraum.",
|
||||
"keys": ["nutrition_score"],
|
||||
},
|
||||
{
|
||||
"key": "carbs",
|
||||
"category": "KH (Ø)",
|
||||
"icon": "🌾",
|
||||
"value": f"{avg_carbs} g",
|
||||
"sublabel": "Kohlenhydrate / Tag",
|
||||
"status": "good",
|
||||
"verdict": "Gut",
|
||||
"hoverTop": "Durchschnittliche Kohlenhydrate",
|
||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
||||
"keys": ["nutrition_summary"],
|
||||
},
|
||||
{
|
||||
"key": "fat",
|
||||
"category": "Fett (Ø)",
|
||||
"icon": "🧈",
|
||||
"value": f"{avg_fat} g",
|
||||
"sublabel": "Fett / Tag",
|
||||
"status": "good",
|
||||
"verdict": "Gut",
|
||||
"hoverTop": "Durchschnittliches Fett",
|
||||
"hoverBody": "Summe der täglichen Werte im Zeitraum, gemittelt.",
|
||||
"keys": ["nutrition_summary"],
|
||||
},
|
||||
]
|
||||
|
||||
if not targets_ok:
|
||||
tiles.append(
|
||||
{
|
||||
"key": "eval-protein",
|
||||
"category": "Protein",
|
||||
"icon": "🥩",
|
||||
"value": f"{avg_protein}g",
|
||||
"sublabel": "Referenzgewicht fehlt",
|
||||
"status": "warn",
|
||||
"verdict": _verdict("warn"),
|
||||
"hoverTop": "Protein-Ziel nicht berechenbar",
|
||||
"hoverBody": "Für 1,6–2,2 g/kg wird ein aktuelles Körpergewicht benötigt.",
|
||||
"keys": ["protein_adequacy"],
|
||||
}
|
||||
)
|
||||
elif not protein_ok:
|
||||
miss = max(0, pt_low - round(avg_protein))
|
||||
tiles.append(
|
||||
{
|
||||
"key": "eval-protein",
|
||||
"category": "Protein",
|
||||
"icon": "🥩",
|
||||
"value": f"{avg_protein}g",
|
||||
"sublabel": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||||
"status": "bad",
|
||||
"verdict": _verdict("bad"),
|
||||
"hoverTop": f"Unterversorgung: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||||
"hoverBody": (
|
||||
f"1,6–2,2g/kg KG. Fehlend: ~{miss}g täglich. "
|
||||
"Konsequenz: Muskelverlust bei Defizit."
|
||||
),
|
||||
"keys": ["protein_adequacy", "nutrition_score"],
|
||||
}
|
||||
)
|
||||
else:
|
||||
tiles.append(
|
||||
{
|
||||
"key": "eval-protein",
|
||||
"category": "Protein",
|
||||
"icon": "🥩",
|
||||
"value": f"{avg_protein}g",
|
||||
"sublabel": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||||
"status": "good",
|
||||
"verdict": _verdict("good"),
|
||||
"hoverTop": f"Gut: {avg_protein}g/Tag (Ziel {pt_low}–{pt_high}g)",
|
||||
"hoverBody": "Ausreichend für Muskelerhalt und -aufbau.",
|
||||
"keys": ["protein_adequacy", "nutrition_score"],
|
||||
}
|
||||
)
|
||||
|
||||
if prot_pct < 20 and total_macro_kcal > 0:
|
||||
tiles.append(
|
||||
{
|
||||
"key": "eval-macro-pct",
|
||||
"category": "Makro-Anteil",
|
||||
"icon": "📊",
|
||||
"value": f"{prot_pct}%",
|
||||
"sublabel": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
||||
"status": "warn",
|
||||
"verdict": _verdict("warn"),
|
||||
"hoverTop": f"Protein-Anteil niedrig: {prot_pct}% der Kalorien",
|
||||
"hoverBody": (
|
||||
f"Empfehlung oft 25–35%. Aktuell: {prot_pct}% P / {kh_pct}% KH / {fat_pct}% F"
|
||||
),
|
||||
"keys": ["nutrition_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
return tiles
|
||||
|
||||
|
||||
def build_macro_donut_from_averages(navg: Dict[str, Any]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Anteile in % der Makro-kcal + Gramm für Legende."""
|
||||
p = float(navg.get("protein_avg") or 0)
|
||||
c = float(navg.get("carbs_avg") or 0)
|
||||
f = float(navg.get("fat_avg") or 0)
|
||||
pkcal, ckcal, fkcal = p * 4, c * 4, f * 9
|
||||
tot = pkcal + ckcal + fkcal
|
||||
if tot <= 0:
|
||||
return None
|
||||
return [
|
||||
{"name": "Protein", "value": round(pkcal / tot * 100), "color": "#059669", "grams": round(p, 1)},
|
||||
{"name": "KH", "value": round(ckcal / tot * 100), "color": "#EA580C", "grams": round(c, 1)},
|
||||
{"name": "Fett", "value": round(fkcal / tot * 100), "color": "#2563EB", "grams": round(f, 1)},
|
||||
]
|
||||
|
|
@ -20,6 +20,7 @@ Phase 0c: Multi-Layer Architecture
|
|||
Version: 1.0
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from typing import Dict, List, Optional
|
||||
from datetime import datetime, timedelta, date
|
||||
from db import get_db, get_cursor, r2d
|
||||
|
|
@ -110,7 +111,9 @@ def _get_profile_goal_mode(profile_id: str) -> str:
|
|||
|
||||
def get_nutrition_average_data(
|
||||
profile_id: str,
|
||||
days: int = 30
|
||||
days: int = 30,
|
||||
*,
|
||||
all_history: bool = False,
|
||||
) -> Dict:
|
||||
"""
|
||||
Get average nutrition values for all macros.
|
||||
|
|
@ -136,11 +139,18 @@ def get_nutrition_average_data(
|
|||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
cutoff = None if all_history else (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Mean over calendar days (per-day sums), not over raw log rows.
|
||||
if cutoff:
|
||||
inner_where = "WHERE profile_id=%s AND date >= %s"
|
||||
params = (profile_id, cutoff)
|
||||
else:
|
||||
inner_where = "WHERE profile_id=%s"
|
||||
params = (profile_id,)
|
||||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
f"""SELECT
|
||||
AVG(daily_kcal) AS kcal_avg,
|
||||
AVG(daily_protein) AS protein_avg,
|
||||
AVG(daily_carbs) AS carbs_avg,
|
||||
|
|
@ -153,10 +163,10 @@ def get_nutrition_average_data(
|
|||
COALESCE(SUM(carbs_g), 0)::float AS daily_carbs,
|
||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
{inner_where}
|
||||
GROUP BY date
|
||||
) AS daily""",
|
||||
(profile_id, cutoff),
|
||||
params,
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
||||
|
|
@ -494,8 +504,6 @@ def get_macro_consistency_data(
|
|||
"data_points": len(rows)
|
||||
}
|
||||
|
||||
import statistics
|
||||
|
||||
protein_pcts = []
|
||||
carbs_pcts = []
|
||||
fat_pcts = []
|
||||
|
|
@ -561,6 +569,136 @@ def get_macro_consistency_data(
|
|||
}
|
||||
|
||||
|
||||
def get_weekly_macro_distribution_chart_data(profile_id: str, weeks: int) -> Dict:
|
||||
"""
|
||||
Chart E3: gestapelte Wochenbalken (Makro-%), gleiche Logik wie /charts/weekly-macro-distribution.
|
||||
"""
|
||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
|
||||
AND fat_g IS NOT NULL AND kcal > 0
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 7:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": [],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)",
|
||||
},
|
||||
}
|
||||
|
||||
weekly_data: Dict[str, Dict[str, List[float]]] = {}
|
||||
for row in rows:
|
||||
date_obj = row["date"] if isinstance(row["date"], datetime) else datetime.fromisoformat(str(row["date"]))
|
||||
iso_week = date_obj.strftime("%Y-W%V")
|
||||
|
||||
if iso_week not in weekly_data:
|
||||
weekly_data[iso_week] = {
|
||||
"protein": [],
|
||||
"carbs": [],
|
||||
"fat": [],
|
||||
"kcal": [],
|
||||
}
|
||||
|
||||
weekly_data[iso_week]["protein"].append(safe_float(row["protein_g"]))
|
||||
weekly_data[iso_week]["carbs"].append(safe_float(row["carbs_g"]))
|
||||
weekly_data[iso_week]["fat"].append(safe_float(row["fat_g"]))
|
||||
weekly_data[iso_week]["kcal"].append(safe_float(row["kcal"]))
|
||||
|
||||
labels: List[str] = []
|
||||
protein_pcts: List[float] = []
|
||||
carbs_pcts: List[float] = []
|
||||
fat_pcts: List[float] = []
|
||||
|
||||
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
||||
data = weekly_data[iso_week]
|
||||
|
||||
avg_protein = sum(data["protein"]) / len(data["protein"]) if data["protein"] else 0
|
||||
avg_carbs = sum(data["carbs"]) / len(data["carbs"]) if data["carbs"] else 0
|
||||
avg_fat = sum(data["fat"]) / len(data["fat"]) if data["fat"] else 0
|
||||
|
||||
protein_kcal = avg_protein * 4
|
||||
carbs_kcal = avg_carbs * 4
|
||||
fat_kcal = avg_fat * 9
|
||||
|
||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
||||
|
||||
if total_kcal > 0:
|
||||
labels.append(f"KW {iso_week[-2:]}")
|
||||
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
||||
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
||||
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
||||
|
||||
protein_cv = (
|
||||
statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100
|
||||
if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0
|
||||
else 0
|
||||
)
|
||||
carbs_cv = (
|
||||
statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100
|
||||
if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0
|
||||
else 0
|
||||
)
|
||||
fat_cv = (
|
||||
statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100
|
||||
if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Protein (%)",
|
||||
"data": protein_pcts,
|
||||
"backgroundColor": "#1D9E75",
|
||||
"stack": "macro",
|
||||
},
|
||||
{
|
||||
"label": "Kohlenhydrate (%)",
|
||||
"data": carbs_pcts,
|
||||
"backgroundColor": "#F59E0B",
|
||||
"stack": "macro",
|
||||
},
|
||||
{
|
||||
"label": "Fett (%)",
|
||||
"data": fat_pcts,
|
||||
"backgroundColor": "#EF4444",
|
||||
"stack": "macro",
|
||||
},
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
||||
"data_points": len(rows),
|
||||
"weeks_analyzed": len(labels),
|
||||
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
||||
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
||||
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
||||
"protein_cv": round(protein_cv, 1),
|
||||
"carbs_cv": round(carbs_cv, 1),
|
||||
"fat_cv": round(fat_cv, 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calculated Metrics (migrated from calculations/nutrition_metrics.py)
|
||||
# ============================================================================
|
||||
|
|
|
|||
283
backend/data_layer/nutrition_viz.py
Normal file
283
backend/data_layer/nutrition_viz.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
"""
|
||||
Layer 2b: Ernährungs-Verlauf — ein Bundle für die UI (Issue #53).
|
||||
|
||||
Single Source: nutrition_metrics + dieselben Tabellen wie Ernährungs-Platzhalter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from data_layer.nutrition_interpretation import (
|
||||
build_macro_donut_from_averages,
|
||||
build_nutrition_history_kpi_tiles,
|
||||
)
|
||||
from data_layer.nutrition_metrics import (
|
||||
estimate_tdee_kcal_from_latest_weight,
|
||||
get_energy_balance_data,
|
||||
get_nutrition_average_data,
|
||||
get_protein_targets_data,
|
||||
get_weekly_macro_distribution_chart_data,
|
||||
)
|
||||
from data_layer.utils import safe_float
|
||||
|
||||
|
||||
def _cutoff_sql(days: int) -> Optional[str]:
|
||||
if days >= 9999:
|
||||
return None
|
||||
return (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _iso(d: Any) -> Optional[str]:
|
||||
if d is None:
|
||||
return None
|
||||
if hasattr(d, "isoformat"):
|
||||
return d.isoformat()[:10]
|
||||
return str(d)[:10]
|
||||
|
||||
|
||||
def _rolling_avg(rows: List[Dict[str, Any]], key: str, window: int) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for i, d in enumerate(rows):
|
||||
sl = rows[max(0, i - window + 1) : i + 1]
|
||||
vals: List[float] = []
|
||||
for x in sl:
|
||||
v = safe_float(x.get(key))
|
||||
if v is not None:
|
||||
vals.append(v)
|
||||
if not vals:
|
||||
out.append({**d, f"{key}_avg": None})
|
||||
continue
|
||||
avg = round(sum(vals) / len(vals), 1)
|
||||
out.append({**d, f"{key}_avg": avg})
|
||||
return out
|
||||
|
||||
|
||||
def _has_nutrition_entries(profile_id: str) -> bool:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT 1 FROM nutrition_log WHERE profile_id=%s LIMIT 1",
|
||||
(profile_id,),
|
||||
)
|
||||
return cur.fetchone() is not None
|
||||
|
||||
|
||||
def _last_nutrition_date(profile_id: str) -> Optional[str]:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"SELECT MAX(date) AS d FROM nutrition_log WHERE profile_id=%s",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row or row["d"] is None:
|
||||
return None
|
||||
return _iso(row["d"])
|
||||
|
||||
|
||||
def _fetch_daily_macro_totals(profile_id: str, cutoff: Optional[str]) -> List[Dict[str, Any]]:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if cutoff:
|
||||
cur.execute(
|
||||
"""SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
ORDER BY date ASC""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""SELECT date,
|
||||
COALESCE(SUM(kcal), 0)::float AS kcal,
|
||||
COALESCE(SUM(protein_g), 0)::float AS protein_g,
|
||||
COALESCE(SUM(carbs_g), 0)::float AS carbs_g,
|
||||
COALESCE(SUM(fat_g), 0)::float AS fat_g
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s
|
||||
GROUP BY date
|
||||
ORDER BY date ASC""",
|
||||
(profile_id,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
def _kcal_weight_points_for_window(
|
||||
profile_id: str, cutoff: Optional[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Gemeinsame Tage: Tages-kcal vs. Gewicht; gleiche Idee wie /nutrition/correlations, gefiltert."""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
if cutoff:
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(kcal)::float AS kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
||||
GROUP BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"""SELECT date, SUM(kcal)::float AS kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND kcal IS NOT NULL
|
||||
GROUP BY date""",
|
||||
(profile_id,),
|
||||
)
|
||||
nk = { _iso(r["date"]): safe_float(r["kcal"]) for r in cur.fetchall() }
|
||||
|
||||
if cutoff:
|
||||
cur.execute(
|
||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s AND date >= %s ORDER BY date",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date",
|
||||
(profile_id,),
|
||||
)
|
||||
wk = { _iso(r["date"]): safe_float(r["weight"]) for r in cur.fetchall() if r.get("weight") is not None }
|
||||
|
||||
common = sorted(set(nk) & set(wk))
|
||||
raw: List[Dict[str, Any]] = []
|
||||
for ds in common:
|
||||
raw.append({"date": ds, "kcal": nk[ds], "weight": wk[ds]})
|
||||
rolled = _rolling_avg(raw, "kcal", 7)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for r in rolled:
|
||||
out.append(
|
||||
{
|
||||
"date": r["date"],
|
||||
"kcal": r.get("kcal"),
|
||||
"weight": r.get("weight"),
|
||||
"kcal_avg": r.get("kcal_avg"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_nutrition_history_viz_bundle(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Layer 2b Bundle für Verlauf «Ernährung».
|
||||
|
||||
days: Analysefenster (>=9999 = gesamte Historie für Mittelwerte / Reihen).
|
||||
"""
|
||||
if not _has_nutrition_entries(profile_id):
|
||||
return {
|
||||
"confidence": "insufficient",
|
||||
"has_nutrition_entries": False,
|
||||
"message": "Noch keine Ernährungsdaten",
|
||||
"kpi_tiles": [],
|
||||
"summary": {},
|
||||
"daily_macros": [],
|
||||
"donut_avg_pct": None,
|
||||
"kcal_vs_weight": {"points": [], "tdee_reference_kcal": None, "common_days_count": 0},
|
||||
"weekly_macro_chart": {},
|
||||
"tdee_reference_kcal": None,
|
||||
"energy_balance_meta": {},
|
||||
"interpretation_tiles": [],
|
||||
"meta": {"layer_1": "nutrition_metrics", "layer_2b": "nutrition_viz"},
|
||||
}
|
||||
|
||||
all_history = days >= 9999
|
||||
eff_days = 3650 if all_history else max(7, min(int(days), 3650))
|
||||
cutoff = _cutoff_sql(days)
|
||||
|
||||
navg = get_nutrition_average_data(profile_id, eff_days, all_history=all_history)
|
||||
targets = get_protein_targets_data(profile_id)
|
||||
energy_days = eff_days if not all_history else min(9999, 3650)
|
||||
energy_meta = get_energy_balance_data(profile_id, energy_days)
|
||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
||||
if tdee is None:
|
||||
tdee = safe_float(energy_meta.get("estimated_tdee")) or None
|
||||
else:
|
||||
tdee = float(tdee)
|
||||
|
||||
daily_rows = _fetch_daily_macro_totals(profile_id, cutoff)
|
||||
daily_macros: List[Dict[str, Any]] = []
|
||||
for r in daily_rows:
|
||||
daily_macros.append(
|
||||
{
|
||||
"date": _iso(r["date"]),
|
||||
"kcal": round(safe_float(r.get("kcal")) or 0),
|
||||
"Protein": round(safe_float(r.get("protein_g")) or 0),
|
||||
"KH": round(safe_float(r.get("carbs_g")) or 0),
|
||||
"Fett": round(safe_float(r.get("fat_g")) or 0),
|
||||
}
|
||||
)
|
||||
|
||||
date_span_label = ""
|
||||
if daily_macros:
|
||||
date_span_label = f"{daily_macros[0]['date']} – {daily_macros[-1]['date']}"
|
||||
|
||||
n_days = int(navg.get("data_points") or 0)
|
||||
kpi_tiles = build_nutrition_history_kpi_tiles(
|
||||
navg, targets, date_span_label or "—", max(1, n_days)
|
||||
)
|
||||
|
||||
donut = build_macro_donut_from_averages(navg)
|
||||
|
||||
kw_points = _kcal_weight_points_for_window(profile_id, cutoff)
|
||||
pt_low = round(float(targets.get("protein_target_low") or 0))
|
||||
|
||||
chart_days_for_pipeline = 90 if all_history else max(7, min(eff_days, 365))
|
||||
weeks_for_weekly = max(4, min(52, (chart_days_for_pipeline + 6) // 7))
|
||||
weekly_chart = get_weekly_macro_distribution_chart_data(profile_id, weeks_for_weekly)
|
||||
|
||||
conf = navg.get("confidence") or "medium"
|
||||
if targets.get("confidence") == "insufficient":
|
||||
conf = "insufficient"
|
||||
|
||||
return {
|
||||
"confidence": conf,
|
||||
"has_nutrition_entries": True,
|
||||
"days_requested": days,
|
||||
"effective_window_days": eff_days,
|
||||
"nutrition_charts_days": chart_days_for_pipeline,
|
||||
"weekly_macro_weeks_used": weeks_for_weekly,
|
||||
"last_updated": _last_nutrition_date(profile_id),
|
||||
"summary": {
|
||||
"kcal_avg": navg.get("kcal_avg"),
|
||||
"protein_avg": navg.get("protein_avg"),
|
||||
"carbs_avg": navg.get("carbs_avg"),
|
||||
"fat_avg": navg.get("fat_avg"),
|
||||
"data_points": navg.get("data_points"),
|
||||
"days_analyzed": navg.get("days_analyzed"),
|
||||
"protein_target_low": targets.get("protein_target_low"),
|
||||
"protein_target_high": targets.get("protein_target_high"),
|
||||
"reference_weight_kg": targets.get("current_weight"),
|
||||
},
|
||||
"kpi_tiles": kpi_tiles,
|
||||
"interpretation_tiles": [],
|
||||
"daily_macros": daily_macros,
|
||||
"donut_avg_pct": donut,
|
||||
"protein_reference_line_g": pt_low,
|
||||
"kcal_vs_weight": {
|
||||
"points": kw_points,
|
||||
"tdee_reference_kcal": tdee,
|
||||
"common_days_count": len(kw_points),
|
||||
},
|
||||
"weekly_macro_chart": weekly_chart,
|
||||
"tdee_reference_kcal": tdee,
|
||||
"energy_balance_meta": {
|
||||
"energy_balance": energy_meta.get("energy_balance"),
|
||||
"avg_intake": energy_meta.get("avg_intake"),
|
||||
"estimated_tdee": energy_meta.get("estimated_tdee"),
|
||||
"status": energy_meta.get("status"),
|
||||
"confidence": energy_meta.get("confidence"),
|
||||
"data_points": energy_meta.get("data_points"),
|
||||
},
|
||||
"meta": {
|
||||
"layer_1": "nutrition_metrics",
|
||||
"layer_2b": "nutrition_viz",
|
||||
"issue": "53-phase-0c",
|
||||
},
|
||||
}
|
||||
|
|
@ -32,12 +32,14 @@ from data_layer.body_metrics import (
|
|||
get_circumference_summary_data
|
||||
)
|
||||
from data_layer.body_viz import get_body_history_viz_bundle
|
||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||
from data_layer.nutrition_metrics import (
|
||||
get_nutrition_average_data,
|
||||
get_protein_targets_data,
|
||||
get_protein_adequacy_data,
|
||||
get_macro_consistency_data,
|
||||
get_energy_balance_data,
|
||||
get_weekly_macro_distribution_chart_data,
|
||||
)
|
||||
from data_layer.activity_metrics import (
|
||||
get_activity_summary_data,
|
||||
|
|
@ -265,6 +267,26 @@ def get_body_history_viz(
|
|||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/nutrition-history-viz")
|
||||
def get_nutrition_history_viz(
|
||||
days: int = Query(
|
||||
default=90,
|
||||
ge=7,
|
||||
le=9999,
|
||||
description="Analysefenster in Tagen (9999 = gesamte Historie)",
|
||||
),
|
||||
session: dict = Depends(require_auth),
|
||||
) -> Dict:
|
||||
"""
|
||||
Layer 2b: Ein Bundle für Verlauf «Ernährung» — Kennzahlen, Reihen, TDEE-Referenz, Wochen-Chart.
|
||||
|
||||
Alle Kennzahlen aus nutrition_metrics (gleiche Logik wie Platzhalter / Chart-Endpunkte).
|
||||
"""
|
||||
profile_id = session["profile_id"]
|
||||
bundle = get_nutrition_history_viz_bundle(profile_id, days)
|
||||
return serialize_dates(bundle)
|
||||
|
||||
|
||||
@router.get("/circumferences")
|
||||
def get_circumferences_chart(
|
||||
max_age_days: int = Query(default=90, ge=7, le=365),
|
||||
|
|
@ -830,136 +852,10 @@ def get_weekly_macro_distribution_chart(
|
|||
Weekly macro distribution (E3) - Konzept-konform.
|
||||
|
||||
100%-gestapelter Wochenbalken statt Pie Chart.
|
||||
Shows macro consistency across weeks, not just overall average.
|
||||
|
||||
Args:
|
||||
weeks: Number of weeks to analyze (4-52, default 12)
|
||||
session: Auth session (injected)
|
||||
|
||||
Returns:
|
||||
Chart.js stacked bar chart with weekly macro percentages
|
||||
Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
from db import get_db, get_cursor
|
||||
import statistics
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
||||
FROM nutrition_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
|
||||
AND fat_g IS NOT NULL AND kcal > 0
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows or len(rows) < 7:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": len(rows) if rows else 0,
|
||||
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)"
|
||||
}
|
||||
}
|
||||
|
||||
# Group by ISO week
|
||||
weekly_data = {}
|
||||
for row in rows:
|
||||
date_obj = row['date'] if isinstance(row['date'], datetime) else datetime.fromisoformat(str(row['date']))
|
||||
iso_week = date_obj.strftime('%Y-W%V')
|
||||
|
||||
if iso_week not in weekly_data:
|
||||
weekly_data[iso_week] = {
|
||||
'protein': [],
|
||||
'carbs': [],
|
||||
'fat': [],
|
||||
'kcal': []
|
||||
}
|
||||
|
||||
weekly_data[iso_week]['protein'].append(safe_float(row['protein_g']))
|
||||
weekly_data[iso_week]['carbs'].append(safe_float(row['carbs_g']))
|
||||
weekly_data[iso_week]['fat'].append(safe_float(row['fat_g']))
|
||||
weekly_data[iso_week]['kcal'].append(safe_float(row['kcal']))
|
||||
|
||||
# Calculate weekly averages and percentages
|
||||
labels = []
|
||||
protein_pcts = []
|
||||
carbs_pcts = []
|
||||
fat_pcts = []
|
||||
|
||||
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
||||
data = weekly_data[iso_week]
|
||||
|
||||
avg_protein = sum(data['protein']) / len(data['protein']) if data['protein'] else 0
|
||||
avg_carbs = sum(data['carbs']) / len(data['carbs']) if data['carbs'] else 0
|
||||
avg_fat = sum(data['fat']) / len(data['fat']) if data['fat'] else 0
|
||||
|
||||
# Convert to kcal
|
||||
protein_kcal = avg_protein * 4
|
||||
carbs_kcal = avg_carbs * 4
|
||||
fat_kcal = avg_fat * 9
|
||||
|
||||
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
||||
|
||||
if total_kcal > 0:
|
||||
labels.append(f"KW {iso_week[-2:]}")
|
||||
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
||||
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
||||
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
||||
|
||||
# Calculate variation coefficient (Variationskoeffizient)
|
||||
protein_cv = statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 else 0
|
||||
carbs_cv = statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 else 0
|
||||
fat_cv = statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 else 0
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Protein (%)",
|
||||
"data": protein_pcts,
|
||||
"backgroundColor": "#1D9E75",
|
||||
"stack": "macro"
|
||||
},
|
||||
{
|
||||
"label": "Kohlenhydrate (%)",
|
||||
"data": carbs_pcts,
|
||||
"backgroundColor": "#F59E0B",
|
||||
"stack": "macro"
|
||||
},
|
||||
{
|
||||
"label": "Fett (%)",
|
||||
"data": fat_pcts,
|
||||
"backgroundColor": "#EF4444",
|
||||
"stack": "macro"
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
||||
"data_points": len(rows),
|
||||
"weeks_analyzed": len(labels),
|
||||
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
||||
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
||||
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
||||
"protein_cv": round(protein_cv, 1),
|
||||
"carbs_cv": round(carbs_cv, 1),
|
||||
"fat_cv": round(fat_cv, 1)
|
||||
}
|
||||
}
|
||||
return get_weekly_macro_distribution_chart_data(profile_id, weeks)
|
||||
|
||||
|
||||
@router.get("/nutrition-adherence-score")
|
||||
|
|
|
|||
|
|
@ -699,62 +699,50 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
|||
)
|
||||
}
|
||||
|
||||
function buildNutritionKpiTiles({
|
||||
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
|
||||
}) {
|
||||
const tiles = [
|
||||
{
|
||||
key: 'kcal',
|
||||
category: 'Kalorien (Ø)',
|
||||
icon: '🔥',
|
||||
value: `${avgKcal} kcal`,
|
||||
sublabel: dateSpanLabel,
|
||||
status: 'good',
|
||||
verdict: '',
|
||||
hoverTop: 'Durchschnittliche tägliche Energie',
|
||||
hoverBody: `Mittel über ${n} Tage mit Ernährungseinträgen im gewählten Zeitraum.`,
|
||||
},
|
||||
{
|
||||
key: 'carbs',
|
||||
category: 'KH (Ø)',
|
||||
icon: '🌾',
|
||||
value: `${avgCarbs} g`,
|
||||
sublabel: 'Kohlenhydrate / Tag',
|
||||
status: 'good',
|
||||
verdict: '',
|
||||
hoverTop: 'Durchschnittliche Kohlenhydrate',
|
||||
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
|
||||
},
|
||||
{
|
||||
key: 'fat',
|
||||
category: 'Fett (Ø)',
|
||||
icon: '🧈',
|
||||
value: `${avgFat} g`,
|
||||
sublabel: 'Fett / Tag',
|
||||
status: 'good',
|
||||
verdict: '',
|
||||
hoverTop: 'Durchschnittliches Fett',
|
||||
hoverBody: 'Summe der täglichen Werte im Zeitraum, gemittelt.',
|
||||
},
|
||||
]
|
||||
macroRules.forEach((r, i) => {
|
||||
tiles.push({
|
||||
key: `eval-${i}`,
|
||||
category: r.category,
|
||||
icon: r.icon,
|
||||
value: r.value,
|
||||
sublabel: r.title.length > 36 ? `${r.title.slice(0, 34)}…` : r.title,
|
||||
status: r.status,
|
||||
verdict: verdictShort(r.status === 'warn' ? 'warn' : r.status === 'bad' ? 'bad' : 'good'),
|
||||
hoverTop: r.title,
|
||||
hoverBody: r.detail,
|
||||
})
|
||||
})
|
||||
return tiles
|
||||
/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */
|
||||
function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) {
|
||||
if (vizKcalWeight?.points?.length >= 5) {
|
||||
const tdee = vizKcalWeight.tdee_reference_kcal
|
||||
const kcalVsW = vizKcalWeight.points.map(d => ({
|
||||
...d,
|
||||
date: fmtDate(d.date),
|
||||
}))
|
||||
const n = vizKcalWeight.common_days_count ?? kcalVsW.length
|
||||
const tdeeLabel = tdee != null && tdee > 0 ? Math.round(tdee) : null
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Kalorien (Ø 7 Tage) vs. Gewicht
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Gleitender 7-Tage-Mittelwert der Kalorien vs. tägliches Gewicht (gemeinsame Tage). Orange: kcal · Blau: Gewicht.
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={kcalVsW} margin={{ top: 4, right: 8, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(kcalVsW.length / 6) - 1)} />
|
||||
<YAxis yAxisId="kcal" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<YAxis yAxisId="weight" orientation="right" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} domain={['auto', 'auto']} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||
formatter={(v, n) => [`${Math.round(v)} ${n === 'weight' ? 'kg' : 'kcal'}`, n === 'kcal_avg' ? 'Ø Kalorien' : 'Gewicht']}
|
||||
/>
|
||||
{tdeeLabel != null && (
|
||||
<ReferenceLine yAxisId="kcal" y={tdeeLabel} stroke="#EA580C" strokeDasharray="6 4" strokeWidth={1.2} />
|
||||
)}
|
||||
<Line yAxisId="kcal" type="monotone" dataKey="kcal_avg" stroke="#EA580C" strokeWidth={2.5} dot={false} name="kcal_avg" />
|
||||
<Line yAxisId="weight" type="monotone" dataKey="weight" stroke="#2563EB" strokeWidth={2.5} dot={{ r: 2, fill: '#2563EB' }} name="weight" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 6 }}>
|
||||
{tdeeLabel != null
|
||||
? `Referenz TDEE ~${tdeeLabel} kcal (Data Layer, gestrichelt) · ${n} gemeinsame Tage`
|
||||
: `Keine TDEE-Referenz (Gewicht/Demografie) · ${n} gemeinsame Tage`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Kalorien (Ø 7T) vs. Gewicht — gleiche Logik wie früher unter Korrelationen. */
|
||||
function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime }) {
|
||||
const raw = (corrRows || []).filter(d => {
|
||||
if (!d.kcal || d.weight == null) return false
|
||||
const ds = typeof d.date === 'string' ? d.date.slice(0, 10) : dayjs(d.date).format('YYYY-MM-DD')
|
||||
|
|
@ -794,21 +782,20 @@ function KcalVsWeightChart({ corrData: corrRows, profile, cutoffDate, allTime })
|
|||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', textAlign: 'center', marginTop: 6 }}>
|
||||
Referenz TDEE ~{tdee} kcal (Mifflin ×1,4, gestrichelt) · {raw.length} gemeinsame Tage
|
||||
Referenz TDEE ~{tdee} kcal (Fallback Mifflin ×1,4) · {raw.length} gemeinsame Tage
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
||||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs, corrData }) {
|
||||
/** Layer 2b: Kennzahlen und Reihen nur aus GET /charts/nutrition-history-viz (nutrition_metrics). */
|
||||
function NutritionSection({ profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
||||
const [period, setPeriod] = useState(30)
|
||||
const [groupedGoals, setGroupedGoals] = useState(null)
|
||||
const chartDays = period === 9999 ? 90 : period
|
||||
const weeks = Math.max(4, Math.min(52, Math.ceil(chartDays / 7)))
|
||||
const [weeklyMacro, setWeeklyMacro] = useState(null)
|
||||
const [wmLoading, setWmLoading] = useState(false)
|
||||
const [wmError, setWmError] = useState(null)
|
||||
const [viz, setViz] = useState(null)
|
||||
const [vizLoad, setVizLoad] = useState(true)
|
||||
const [vizErr, setVizErr] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -820,120 +807,110 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
|
|||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setWmLoading(true)
|
||||
setWmError(null)
|
||||
api.getWeeklyMacroDistributionChart(weeks)
|
||||
.then(d => { if (!cancelled) setWeeklyMacro(d) })
|
||||
.catch(e => { if (!cancelled) setWmError(e.message || 'Laden fehlgeschlagen') })
|
||||
.finally(() => { if (!cancelled) setWmLoading(false) })
|
||||
setViz(null)
|
||||
setVizLoad(true)
|
||||
setVizErr(null)
|
||||
const daysReq = period === 9999 ? 9999 : period
|
||||
api.getNutritionHistoryViz(daysReq)
|
||||
.then(v => { if (!cancelled) setViz(v) })
|
||||
.catch(e => { if (!cancelled) setVizErr(e.message || 'Laden fehlgeschlagen') })
|
||||
.finally(() => { if (!cancelled) setVizLoad(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [weeks])
|
||||
}, [period])
|
||||
|
||||
if (!nutrition?.length) {
|
||||
if (vizLoad) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
<div className="spinner" style={{ margin: 24 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (vizErr) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
|
||||
<div className="card" style={{ color: 'var(--danger)', marginTop: 8 }}>{vizErr}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!viz?.has_nutrition_entries) {
|
||||
return (
|
||||
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
||||
)
|
||||
}
|
||||
|
||||
const cutoff = dayjs().subtract(period, 'day').format('YYYY-MM-DD')
|
||||
const filtN = nutrition.filter(d => period === 9999 || d.date >= cutoff)
|
||||
const sorted = [...filtN].sort((a, b) => a.date.localeCompare(b.date))
|
||||
const summary = viz.summary || {}
|
||||
const n = Math.max(0, Number(summary.data_points) || 0)
|
||||
const avgKcal = Math.round(Number(summary.kcal_avg) || 0)
|
||||
const ptLow = Math.round(Number(viz.protein_reference_line_g) || 0)
|
||||
const chartDays = viz.nutrition_charts_days || (period === 9999 ? 90 : period)
|
||||
const kpiTiles = (viz.kpi_tiles || []).map(t => ({
|
||||
...t,
|
||||
sublabel: typeof t.sublabel === 'string' && t.sublabel.length > 36 ? `${t.sublabel.slice(0, 34)}…` : t.sublabel,
|
||||
}))
|
||||
const pieData = viz.donut_avg_pct || []
|
||||
const cdMacro = (viz.daily_macros || []).map(d => ({
|
||||
date: fmtDate(d.date),
|
||||
Protein: d.Protein,
|
||||
KH: d.KH,
|
||||
Fett: d.Fett,
|
||||
kcal: d.kcal,
|
||||
}))
|
||||
const weeklyMacro = viz.weekly_macro_chart
|
||||
const wmLoading = false
|
||||
const wmError = null
|
||||
|
||||
if (!filtN.length) {
|
||||
if (!cdMacro.length || n === 0) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import"/>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated} />
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const n = filtN.length
|
||||
const avgKcal = Math.round(filtN.reduce((s, d) => s + (d.kcal || 0), 0) / n)
|
||||
const avgProtein = Math.round(filtN.reduce((s, d) => s + (d.protein_g || 0), 0) / n * 10) / 10
|
||||
const avgFat = Math.round(filtN.reduce((s, d) => s + (d.fat_g || 0), 0) / n * 10) / 10
|
||||
const avgCarbs = Math.round(filtN.reduce((s, d) => s + (d.carbs_g || 0), 0) / n * 10) / 10
|
||||
const latestW = weights?.[0]?.weight || 80
|
||||
const ptLow = Math.round(latestW * 1.6)
|
||||
const ptHigh = Math.round(latestW * 2.2)
|
||||
const proteinOk = avgProtein >= ptLow
|
||||
|
||||
const cdMacro = sorted.map(d => ({
|
||||
date: fmtDate(d.date),
|
||||
Protein: Math.round(d.protein_g || 0),
|
||||
KH: Math.round(d.carbs_g || 0),
|
||||
Fett: Math.round(d.fat_g || 0),
|
||||
kcal: Math.round(d.kcal || 0),
|
||||
}))
|
||||
|
||||
const totalMacroKcal = avgProtein * 4 + avgCarbs * 4 + avgFat * 9
|
||||
const pieData = [
|
||||
{ name: 'Protein', value: Math.round(avgProtein * 4 / totalMacroKcal * 100), color: '#059669' },
|
||||
{ name: 'KH', value: Math.round(avgCarbs * 4 / totalMacroKcal * 100), color: '#EA580C' },
|
||||
{ name: 'Fett', value: Math.round(avgFat * 9 / totalMacroKcal * 100), color: '#2563EB' },
|
||||
]
|
||||
|
||||
const macroRules = []
|
||||
if (!proteinOk) {
|
||||
macroRules.push({
|
||||
status: 'bad', icon: '🥩', category: 'Protein',
|
||||
title: `Unterversorgung: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
||||
detail: `1,6–2,2g/kg KG. Fehlend: ~${Math.max(0, ptLow - Math.round(avgProtein))}g täglich. Konsequenz: Muskelverlust bei Defizit.`,
|
||||
value: `${avgProtein}g`,
|
||||
})
|
||||
} else {
|
||||
macroRules.push({
|
||||
status: 'good', icon: '🥩', category: 'Protein',
|
||||
title: `Gut: ${avgProtein}g/Tag (Ziel ${ptLow}–${ptHigh}g)`,
|
||||
detail: 'Ausreichend für Muskelerhalt und -aufbau.',
|
||||
value: `${avgProtein}g`,
|
||||
})
|
||||
}
|
||||
const protPct = Math.round(avgProtein * 4 / totalMacroKcal * 100)
|
||||
if (protPct < 20) {
|
||||
macroRules.push({
|
||||
status: 'warn', icon: '📊', category: 'Makro-Anteil',
|
||||
title: `Protein-Anteil niedrig: ${protPct}% der Kalorien`,
|
||||
detail: `Empfehlung oft 25–35%. Aktuell: ${protPct}% P / ${Math.round(avgCarbs * 4 / totalMacroKcal * 100)}% KH / ${Math.round(avgFat * 9 / totalMacroKcal * 100)}% F`,
|
||||
value: `${protPct}%`,
|
||||
})
|
||||
}
|
||||
|
||||
const dateSpanLabel = `${sorted[0]?.date?.slice(0, 10) ?? ''} – ${sorted[sorted.length - 1]?.date?.slice(0, 10) ?? ''}`
|
||||
const kpiTiles = buildNutritionKpiTiles({
|
||||
avgKcal, avgCarbs, avgFat, n, macroRules, dateSpanLabel,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={nutrition[0]?.date}/>
|
||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" lastUpdated={viz.last_updated}/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Kennzahlen und Charts nutzen dieselben Datenquellen wie die KI-Platzhalter (Ernährungs-Log, Gewicht).{' '}
|
||||
<strong>Kalorien vs. Gewicht</strong> bezieht gemeinsame Tage aus Ernährung und Gewicht.
|
||||
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
|
||||
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
|
||||
</p>
|
||||
|
||||
<NutritionGoalsStrip grouped={groupedGoals} />
|
||||
|
||||
<KpiTilesOverview tiles={kpiTiles} />
|
||||
|
||||
<KcalVsWeightChart corrData={corrData} profile={profile} cutoffDate={cutoff} allTime={period === 9999} />
|
||||
<KcalVsWeightChart
|
||||
vizKcalWeight={viz.kcal_vs_weight}
|
||||
corrData={[]}
|
||||
profile={profile}
|
||||
cutoffDate=""
|
||||
allTime={period === 9999}
|
||||
/>
|
||||
|
||||
<div className="card" style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Makroverteilung täglich (g) · Fokus Protein
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow} g) nach 1,6 g/kg (Referenzgewicht).
|
||||
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={cdMacro} margin={{ top: 6, right: 8, bottom: 0, left: -18 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={Math.max(0, Math.floor(cdMacro.length / 6) - 1)} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
{ptLow > 0 && (
|
||||
<ReferenceLine y={ptLow} stroke="#059669" strokeDasharray="5 4" strokeWidth={2} label={{ value: `${ptLow}g P`, fontSize: 9, fill: '#059669', position: 'insideTopRight' }} />
|
||||
)}
|
||||
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
|
||||
<Bar dataKey="Fett" stackId="a" fill="#93C5FD" name="Fett" />
|
||||
<Bar dataKey="KH" stackId="a" fill="#FDBA74" name="KH" />
|
||||
|
|
@ -953,6 +930,8 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
|
|||
Ø Makro-Quote ({n} Tage)
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
|
||||
{pieData.length > 0 ? (
|
||||
<>
|
||||
<PieChart width={120} height={120}>
|
||||
<Pie data={pieData} cx={58} cy={58} innerRadius={36} outerRadius={54} dataKey="value" startAngle={90} endAngle={-270}>
|
||||
{pieData.map((e, i) => <Cell key={i} fill={e.color} />)}
|
||||
|
|
@ -966,7 +945,7 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
|
|||
<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
|
||||
{p.grams != null ? `${p.grams}g` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -974,6 +953,10 @@ function NutritionSection({ nutrition, weights, profile, insights, onRequest, lo
|
|||
Ø {avgKcal} kcal/Tag · Anteil der Makro-Kalorien am Tagesumsatz
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Makro-Mittelwerte im Zeitraum.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card nutrition-macro-pair__weekly">
|
||||
|
|
@ -1501,7 +1484,7 @@ export default function History() {
|
|||
</nav>
|
||||
<div className="history-content">
|
||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection nutrition={nutrition} weights={weights} profile={profile} corrData={corrData} {...sp}/>}
|
||||
{tab==='nutrition' && <NutritionSection profile={profile} {...sp}/>}
|
||||
{tab==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user