Ernährungsasuwertungen #94
219
backend/data_layer/nutrition_interpretation.py
Normal file
219
backend/data_layer/nutrition_interpretation.py
Normal 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,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"),
|
||||
"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,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"),
|
||||
"hint": (
|
||||
f"Protein-Kalorienanteil niedrig (P {prot_pct} % / KH {kh_pct} % / F {fat_pct} %); "
|
||||
"Ziel oft 25–35 %."
|
||||
),
|
||||
"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_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)},
|
||||
]
|
||||
|
|
@ -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)
|
||||
# ============================================================================
|
||||
|
|
|
|||
294
backend/data_layer/nutrition_viz.py
Normal file
294
backend/data_layer/nutrition_viz.py
Normal 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",
|
||||
},
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
178
frontend/src/components/KpiTilesOverview.jsx
Normal file
178
frontend/src/components/KpiTilesOverview.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 % 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 (E1–E5). 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6–2,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: 25–35%. 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 Mifflin–St 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}/>}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}`),
|
||||
|
|
|
|||
21
frontend/src/utils/macroChartTheme.js
Normal file
21
frontend/src/utils/macroChartTheme.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user