Merge pull request 'Ernährungsasuwertungen' (#94) from develop into main
Reviewed-on: #94
This commit is contained in:
commit
aa6644f44b
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
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import statistics
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
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(
|
def get_nutrition_average_data(
|
||||||
profile_id: str,
|
profile_id: str,
|
||||||
days: int = 30
|
days: int = 30,
|
||||||
|
*,
|
||||||
|
all_history: bool = False,
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Get average nutrition values for all macros.
|
Get average nutrition values for all macros.
|
||||||
|
|
@ -136,11 +139,18 @@ def get_nutrition_average_data(
|
||||||
"""
|
"""
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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.
|
# 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(
|
cur.execute(
|
||||||
"""SELECT
|
f"""SELECT
|
||||||
AVG(daily_kcal) AS kcal_avg,
|
AVG(daily_kcal) AS kcal_avg,
|
||||||
AVG(daily_protein) AS protein_avg,
|
AVG(daily_protein) AS protein_avg,
|
||||||
AVG(daily_carbs) AS carbs_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(carbs_g), 0)::float AS daily_carbs,
|
||||||
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
COALESCE(SUM(fat_g), 0)::float AS daily_fat
|
||||||
FROM nutrition_log
|
FROM nutrition_log
|
||||||
WHERE profile_id=%s AND date >= %s
|
{inner_where}
|
||||||
GROUP BY date
|
GROUP BY date
|
||||||
) AS daily""",
|
) AS daily""",
|
||||||
(profile_id, cutoff),
|
params,
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
|
@ -494,8 +504,6 @@ def get_macro_consistency_data(
|
||||||
"data_points": len(rows)
|
"data_points": len(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
import statistics
|
|
||||||
|
|
||||||
protein_pcts = []
|
protein_pcts = []
|
||||||
carbs_pcts = []
|
carbs_pcts = []
|
||||||
fat_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)
|
# 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
|
get_circumference_summary_data
|
||||||
)
|
)
|
||||||
from data_layer.body_viz import get_body_history_viz_bundle
|
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 (
|
from data_layer.nutrition_metrics import (
|
||||||
get_nutrition_average_data,
|
get_nutrition_average_data,
|
||||||
get_protein_targets_data,
|
get_protein_targets_data,
|
||||||
get_protein_adequacy_data,
|
get_protein_adequacy_data,
|
||||||
get_macro_consistency_data,
|
get_macro_consistency_data,
|
||||||
get_energy_balance_data,
|
get_energy_balance_data,
|
||||||
|
get_weekly_macro_distribution_chart_data,
|
||||||
|
get_energy_availability_warning_payload,
|
||||||
)
|
)
|
||||||
from data_layer.activity_metrics import (
|
from data_layer.activity_metrics import (
|
||||||
get_activity_summary_data,
|
get_activity_summary_data,
|
||||||
|
|
@ -265,6 +268,26 @@ def get_body_history_viz(
|
||||||
return serialize_dates(bundle)
|
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")
|
@router.get("/circumferences")
|
||||||
def get_circumferences_chart(
|
def get_circumferences_chart(
|
||||||
max_age_days: int = Query(default=90, ge=7, le=365),
|
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.
|
Weekly macro distribution (E3) - Konzept-konform.
|
||||||
|
|
||||||
100%-gestapelter Wochenbalken statt Pie Chart.
|
100%-gestapelter Wochenbalken statt Pie Chart.
|
||||||
Shows macro consistency across weeks, not just overall average.
|
Datenberechnung: data_layer.nutrition_metrics.get_weekly_macro_distribution_chart_data
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return get_weekly_macro_distribution_chart_data(profile_id, weeks)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/nutrition-adherence-score")
|
@router.get("/nutrition-adherence-score")
|
||||||
|
|
@ -1130,87 +1027,10 @@ def get_energy_availability_warning(
|
||||||
"""
|
"""
|
||||||
Energy Availability Warning (E5) - Konzept-konform.
|
Energy Availability Warning (E5) - Konzept-konform.
|
||||||
|
|
||||||
Heuristic warning for potential undernutrition/overtraining.
|
Datenberechnung: data_layer.nutrition_metrics.get_energy_availability_warning_payload
|
||||||
|
|
||||||
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": "..."
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
return get_energy_availability_warning_payload(profile_id, days)
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-volume")
|
@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; }
|
.page-title { font-size: 20px; font-weight: 700; margin-bottom: 16px; }
|
||||||
|
|
||||||
/* Verlauf: Mobile Tabs horizontale Leiste, Desktop vertikal links (P4 / RESPONSIVE_UI §5.2) */
|
/* 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 {
|
.body-kpi-overview {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
.kpi-tiles-card,
|
||||||
.body-kpi-card {
|
.body-kpi-card {
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|
@ -215,11 +218,190 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
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 {
|
.body-kpi-card:hover {
|
||||||
border-color: var(--border2);
|
border-color: var(--border2);
|
||||||
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.07);
|
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 {
|
.history-page__title {
|
||||||
margin-bottom: 12px;
|
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 { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar,
|
LineChart, Line, BarChart, Bar,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
|
||||||
|
ComposedChart, ReferenceArea,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { MACRO_CHART, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const fmtDate = d => dayjs(d).format('DD.MM')
|
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
|
* Nutrition Charts (E1–E5). Verlauf: `showWeeklyMacroDistribution={false}` wenn E3 separat (z. B. neben Donut) gerendert wird.
|
||||||
*
|
|
||||||
* 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)
|
|
||||||
*/
|
*/
|
||||||
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 [energyData, setEnergyData] = useState(null)
|
||||||
const [proteinData, setProteinData] = useState(null)
|
const [proteinData, setProteinData] = useState(null)
|
||||||
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
||||||
|
|
@ -159,16 +226,21 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
}, [days])
|
}, [days, showWeeklyMacroDistribution, hideEnergyAvailabilityCard])
|
||||||
|
|
||||||
const loadCharts = async () => {
|
const loadCharts = async () => {
|
||||||
await Promise.all([
|
const tasks = [
|
||||||
loadEnergyBalance(),
|
loadEnergyBalance(),
|
||||||
loadProteinAdequacy(),
|
loadProteinAdequacy(),
|
||||||
loadMacroWeekly(),
|
|
||||||
loadAdherence(),
|
loadAdherence(),
|
||||||
loadWarning()
|
]
|
||||||
])
|
if (!hideEnergyAvailabilityCard) {
|
||||||
|
tasks.push(loadWarning())
|
||||||
|
}
|
||||||
|
if (showWeeklyMacroDistribution) {
|
||||||
|
tasks.splice(2, 0, loadMacroWeekly())
|
||||||
|
}
|
||||||
|
await Promise.all(tasks)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadEnergyBalance = async () => {
|
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 = () => {
|
const renderEnergyBalance = () => {
|
||||||
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
|
if (!energyData || energyData.metadata?.confidence === 'insufficient') {
|
||||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
const msg = energyData?.metadata?.message || 'Nicht genug Ernährungsdaten für die Energiebilanz.'
|
||||||
Nicht genug Ernährungsdaten (min. 7 Tage)
|
return (
|
||||||
</div>
|
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text3)', fontSize: 12 }}>{msg}</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = energyData.data.labels.map((label, i) => ({
|
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],
|
täglich: energyData.data.datasets[0]?.data[i],
|
||||||
avg7d: energyData.data.datasets[1]?.data[i],
|
avg7d: energyData.data.datasets[1]?.data[i],
|
||||||
avg14d: energyData.data.datasets[2]?.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
|
const balance = energyData.metadata?.energy_balance || 0
|
||||||
|
|
@ -257,111 +330,90 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
Tägliche Aufnahme, gleitende Mittel und geschätzter TDEE — Linien sind farblich getrennt (Legende unten).
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
</div>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
<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} />
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
<Tooltip
|
||||||
<Legend wrapperStyle={{fontSize:10}}/>
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
<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"/>
|
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 6 }} />
|
||||||
<Line type="monotone" dataKey="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/>
|
<Line type="monotone" dataKey="täglich" stroke="#64748B" strokeWidth={2} dot={{ r: 2 }} name="Täglich kcal" />
|
||||||
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/>
|
<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>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{marginTop:8,fontSize:10,textAlign:'center'}}>
|
<div style={{ marginTop: 8, fontSize: 10, textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
<span style={{color:'var(--text3)'}}>
|
<span style={{ color: 'var(--text3)' }}>Ø {energyData.metadata.avg_kcal} kcal/Tag ·</span>
|
||||||
Ø {energyData.metadata.avg_kcal} kcal/Tag ·
|
|
||||||
</span>
|
|
||||||
<span style={{ color: balanceColor, fontWeight: 600, marginLeft: 4 }}>
|
<span style={{ color: balanceColor, fontWeight: 600, marginLeft: 4 }}>
|
||||||
Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag
|
Balance: {balance > 0 ? '+' : ''}
|
||||||
</span>
|
{balance} kcal/Tag
|
||||||
<span style={{color:'var(--text3)',marginLeft:8}}>
|
|
||||||
· {energyData.metadata.data_points} Tage
|
|
||||||
</span>
|
</span>
|
||||||
|
<span style={{ color: 'var(--text3)', marginLeft: 8 }}>· {energyData.metadata.data_points} Tage</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten)
|
// E2: Protein — Zielzone als Fläche, Linien klar von E1 abgrenzbar
|
||||||
const renderProteinAdequacy = () => {
|
const renderProteinAdequacy = () => {
|
||||||
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
|
if (!proteinData || proteinData.metadata?.confidence === 'insufficient') {
|
||||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
const msg = proteinData?.metadata?.message || 'Nicht genug Protein-Daten für dieses Diagramm.'
|
||||||
Nicht genug Protein-Daten (min. 7 Tage)
|
return (
|
||||||
</div>
|
<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) => ({
|
const chartData = proteinData.data.labels.map((label, i) => ({
|
||||||
date: fmtDate(label),
|
date: fmtDate(label),
|
||||||
täglich: proteinData.data.datasets[0]?.data[i],
|
täglich: proteinData.data.datasets[0]?.data[i],
|
||||||
avg7d: proteinData.data.datasets[1]?.data[i],
|
avg7d: proteinData.data.datasets[1]?.data[i],
|
||||||
avg28d: proteinData.data.datasets[2]?.data[i],
|
avg28d: proteinData.data.datasets[2]?.data[i],
|
||||||
targetLow: proteinData.data.datasets[3]?.data[i],
|
|
||||||
targetHigh: proteinData.data.datasets[4]?.data[i]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<div style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
Grüne Zone = empfohlenes Protein-Ziel (g/Tag). Tägliche Werte und Mittel — andere Farben als Energiebilanz oben.
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
</div>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
<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} />
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
<Tooltip
|
||||||
<Legend wrapperStyle={{fontSize:10}}/>
|
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }}
|
||||||
<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"/>
|
{tl != null && th != null && (
|
||||||
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
|
<ReferenceArea y1={tl} y2={th} fill="rgba(16, 185, 129, 0.14)" stroke="#10B981" strokeOpacity={0.35} />
|
||||||
<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"/>
|
<Legend wrapperStyle={{ fontSize: 10, paddingTop: 6 }} />
|
||||||
</LineChart>
|
<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>
|
</ResponsiveContainer>
|
||||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
<div style={{ marginTop: 8, fontSize: 10, color: 'var(--text3)', textAlign: 'center', lineHeight: 1.5 }}>
|
||||||
{proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich ({proteinData.metadata.target_compliance_pct}%)
|
Ziel {tl}–{th} g/Tag · {proteinData.metadata.days_in_target}/{proteinData.metadata.data_points} Tage im Zielbereich (
|
||||||
</div>
|
{proteinData.metadata.target_compliance_pct}%)
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -414,20 +466,22 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}>
|
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
|
||||||
{renderEnergyBalance()}
|
{renderEnergyBalance()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}>
|
<ChartCard title="📊 Protein (Adequacy)" loading={loading.protein} error={errors.protein}>
|
||||||
{renderProteinAdequacy()}
|
{renderProteinAdequacy()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}>
|
{showWeeklyMacroDistribution && (
|
||||||
{renderMacroWeekly()}
|
<ChartCard title="📊 Wöchentliche Makro-Verteilung" loading={loading.macro} error={errors.macro}>
|
||||||
|
<WeeklyMacroDistributionPanel macroWeeklyData={macroWeeklyData} loading={false} error={null} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading.adherence && !errors.adherence && renderAdherence()}
|
{!loading.adherence && !errors.adherence && renderAdherence()}
|
||||||
{!loading.warning && !errors.warning && renderWarning()}
|
{!hideEnergyAvailabilityCard && !loading.warning && !errors.warning && renderWarning()}
|
||||||
</div>
|
</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 { Link } from 'react-router-dom'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { api } from '../../utils/api'
|
import { api } from '../../utils/api'
|
||||||
|
|
@ -6,6 +6,7 @@ import { getBfCategory } from '../../utils/calc'
|
||||||
import { useProfile } from '../../context/ProfileContext'
|
import { useProfile } from '../../context/ProfileContext'
|
||||||
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
import { KPI_KCAL_WINDOW_DEFAULT } from '../../widgetSystem/bodyChartDays'
|
||||||
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
|
import { kpiTileOrderFromConfig } from '../../widgetSystem/kpiBoardTiles'
|
||||||
|
import KpiTilesOverview from '../KpiTilesOverview'
|
||||||
|
|
||||||
const MAX_KPI = 9
|
const MAX_KPI = 9
|
||||||
|
|
||||||
|
|
@ -113,62 +114,63 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
||||||
return buildAutoTileIds(refTiles, hasBf, hasKcal)
|
return buildAutoTileIds(refTiles, hasBf, hasKcal)
|
||||||
}, [manualOrder, refTiles, bf, avgKcal])
|
}, [manualOrder, refTiles, bf, avgKcal])
|
||||||
|
|
||||||
const pushTileForId = useCallback(
|
const kpiTiles = useMemo(() => {
|
||||||
(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 out = []
|
const out = []
|
||||||
for (const id of orderIds) {
|
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
|
return out
|
||||||
}, [orderIds, pushTileForId])
|
}, [orderIds, bf, avgKcal, refByKey])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -185,7 +187,7 @@ export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleTiles.length === 0) {
|
if (kpiTiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="card section-gap">
|
<div className="card section-gap">
|
||||||
<div className="card-title">Kennzahlen</div>
|
<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).'
|
? 'Ausgewählte Kacheln in festgelegter Reihenfolge (ohne Daten werden Kacheln ausgelassen).'
|
||||||
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
|
: `Bis ${MAX_KPI} Kacheln: Referenzwerte, Körperfett, Ø Kalorien (${KPI_KCAL_WINDOW_DEFAULT}T).`}
|
||||||
</p>
|
</p>
|
||||||
<div className="ref-value-tiles-grid">{visibleTiles}</div>
|
<KpiTilesOverview
|
||||||
|
tiles={kpiTiles}
|
||||||
|
heading={null}
|
||||||
|
showTouchHint
|
||||||
|
gridClassName="ref-value-tiles-grid"
|
||||||
|
marginBottom={0}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,12 @@ import { api } from '../utils/api'
|
||||||
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
import { photoMonthKey, photoSortKey, formatPhotoCaption } from '../utils/photoDisplay'
|
||||||
import { getBfCategory } from '../utils/calc'
|
import { getBfCategory } from '../utils/calc'
|
||||||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||||
|
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||||
import Markdown from '../utils/Markdown'
|
import Markdown from '../utils/Markdown'
|
||||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||||
import NutritionCharts from '../components/NutritionCharts'
|
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||||
import RecoveryCharts from '../components/RecoveryCharts'
|
import RecoveryCharts from '../components/RecoveryCharts'
|
||||||
|
import KpiTilesOverview from '../components/KpiTilesOverview'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
@ -232,39 +234,46 @@ function buildBodyKpiTiles({
|
||||||
return tiles
|
return tiles
|
||||||
}
|
}
|
||||||
|
|
||||||
/** KPI-Kacheln: Kurzvergleich sichtbar, ausführlicher Text per nativem Hover (`title`). */
|
function NutritionGoalsStrip({ grouped }) {
|
||||||
function BodyKpiOverview({ tiles }) {
|
const nav = useNavigate()
|
||||||
if (!tiles?.length) return null
|
const goals = (grouped?.nutrition || []).filter(g => g.status === 'active').slice(0, 4)
|
||||||
return (
|
if (!goals.length) return null
|
||||||
<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')
|
|
||||||
return (
|
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
|
<div
|
||||||
key={t.key}
|
key={g.id}
|
||||||
className="body-kpi-card"
|
style={{
|
||||||
style={{ borderLeft: `4px solid ${accent}` }}
|
flex: '1 1 140px',
|
||||||
title={tip}
|
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 }}>
|
<div style={{
|
||||||
<span style={{ fontSize: 14, lineHeight: 1 }}>{t.icon}</span>
|
fontSize: 11, fontWeight: 600, color: 'var(--text2)',
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
|
||||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text3)', textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.category}</div>
|
}}>{g.name || g.label_de || g.goal_type}</div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 700, color: t.valueColor || 'var(--text1)', marginTop: 2, lineHeight: 1.2 }}>{t.value}</div>
|
<div style={{ marginTop: 4, height: 6, background: 'var(--border)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
{t.sublabel && (
|
<div style={{
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 2, lineHeight: 1.25 }}>{t.sublabel}</div>
|
width: `${Math.min(100, Math.max(0, g.progress_pct ?? 0))}%`,
|
||||||
)}
|
height: '100%',
|
||||||
|
background: 'var(--accent)',
|
||||||
|
}} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 4 }}>
|
||||||
<div style={{ fontSize: 10, fontWeight: 700, color: accent, lineHeight: 1.2 }}>{t.verdict}</div>
|
{Math.round(g.progress_pct ?? 0)}% · Ziel {g.target_value}{g.unit ? ` ${g.unit}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -535,7 +544,7 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<BodyKpiOverview tiles={kpiTiles} />
|
<KpiTilesOverview tiles={kpiTiles} />
|
||||||
|
|
||||||
{vizLoading && (
|
{vizLoading && (
|
||||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>Aktualisiere…</div>
|
||||||
|
|
@ -690,191 +699,398 @@ function BodySection({ profile, insights, onRequest, loadingSlug, filterActiveSl
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// ── Nutrition Section ─────────────────────────────────────────────────────────
|
|
||||||
function NutritionSection({ nutrition, weights, profile, insights, onRequest, loadingSlug, filterActiveSlugs }) {
|
/** TDEE-Linie muss in der kcal-Y-Domain liegen (sonst unsichtbar trotz Legende). */
|
||||||
const [period, setPeriod] = useState(30)
|
function kcalVsWeightKcalDomain(points, tdeeRef) {
|
||||||
if (!nutrition?.length) return (
|
const vals = (points || [])
|
||||||
<EmptySection text="Noch keine Ernährungsdaten." to="/nutrition" toLabel="FDDB importieren"/>
|
.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')
|
/** Kalorien (Ø 7T) vs. Gewicht — Daten aus Layer-2b-Bundle (nutrition_metrics / TDEE wie Data Layer). */
|
||||||
const filtN = nutrition.filter(d=>period===9999||d.date>=cutoff)
|
function KcalVsWeightChart({ vizKcalWeight, corrData: corrRows, profile, cutoffDate, allTime }) {
|
||||||
const sorted = [...filtN].sort((a,b)=>a.date.localeCompare(b.date))
|
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>
|
<div>
|
||||||
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
|
<SectionHeader title="🍽️ Ernährung" to="/nutrition" toLabel="Import" />
|
||||||
<PeriodSelector value={period} onChange={setPeriod} />
|
<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."/>
|
<EmptySection text="Keine Einträge im gewählten Zeitraum."/>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div>
|
<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}/>
|
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||||
|
|
||||||
<div style={{display:'flex',gap:6,marginBottom:12,flexWrap:'wrap'}}>
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||||
{[['Ø Kalorien',avgKcal+' kcal','#EF9F27'],['Ø Protein',avgProtein+'g',proteinOk?'#1D9E75':'#D85A30'],
|
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Ernährungs-Data-Layer).{' '}
|
||||||
['Ø Fett',avgFat+'g','#378ADD'],['Ø KH',avgCarbs+'g','#D4537E'],
|
<strong>Kalorien vs. Gewicht</strong> und TDEE-Referenz entsprechen Mifflin–St Jeor × PAL 1,55 bzw. kg-Fallback (32,5 kcal/kg).
|
||||||
['Einträge',n+' T','var(--text3)']].map(([l,v,c])=>(
|
</p>
|
||||||
<div key={l} style={{flex:1,minWidth:60,background:'var(--surface2)',borderRadius:8,
|
|
||||||
padding:'8px 6px',textAlign:'center'}}>
|
<NutritionGoalsStrip grouped={groupedGoals} />
|
||||||
<div style={{fontSize:13,fontWeight:700,color:c}}>{v}</div>
|
|
||||||
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
<KpiTilesOverview tiles={kpiTiles} />
|
||||||
</div>
|
|
||||||
))}
|
<KcalVsWeightChart
|
||||||
</div>
|
vizKcalWeight={viz.kcal_vs_weight}
|
||||||
|
corrData={[]}
|
||||||
|
profile={profile}
|
||||||
|
cutoffDate=""
|
||||||
|
allTime={period === 9999}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Stacked macro bars (daily) */}
|
|
||||||
<div className="card" style={{ marginBottom: 12 }}>
|
<div className="card" style={{ marginBottom: 12 }}>
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||||
Makroverteilung täglich (g) · {sorted[0]?.date?.slice(0,7)} – {sorted[sorted.length-1]?.date?.slice(0,7)}
|
Makroverteilung täglich (g) · Fokus Protein
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveContainer width="100%" height={170}>
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 8 }}>
|
||||||
<BarChart data={cdMacro} margin={{top:4,right:8,bottom:0,left:-20}}>
|
Gestapelte Balken in Gramm; gestrichelte Linie = Protein-Minimum ({ptLow || '—'} g) nach 1,6 g/kg (Referenzgewicht).
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
</div>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
interval={Math.max(0,Math.floor(cdMacro.length/6)-1)}/>
|
<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} />
|
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||||
<ReferenceLine y={ptLow} stroke="#1D9E75" strokeDasharray="4 4" strokeWidth={1.5}
|
{ptLow > 0 && (
|
||||||
label={{value:`Ziel ${ptLow}g P`,fontSize:9,fill:'#1D9E75',position:'insideTopRight'}}/>
|
<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' }} />
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
<Tooltip contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 11 }} formatter={(v, name) => [`${v}g`, name]} />
|
||||||
<div style={{marginBottom:12}}>
|
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein} name="Protein" />
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat} name="Fett" />
|
||||||
{macroRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* New Nutrition Charts (Phase 0c) */}
|
<div className="nutrition-macro-pair">
|
||||||
<div style={{marginTop:16}}>
|
<div className="card nutrition-macro-pair__donut">
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>📊 DETAILLIERTE CHARTS</div>
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>
|
||||||
<NutritionCharts days={period === 9999 ? 90 : period} />
|
Ø Makro-Quote ({n} Tage)
|
||||||
</div>
|
</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}/>
|
<InsightBox insights={insights} slugs={filterActiveSlugs(['ernaehrung'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||||
</div>
|
</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 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
|
const tdee = Math.round(bmr*1.4) // light activity baseline
|
||||||
|
|
||||||
// Chart 1: Kcal vs Weight
|
// Protein vs Lean Mass (only days with both)
|
||||||
const kcalVsW = rollingAvg(filtered.map(d=>({...d,date:fmtDate(d.date)})),'kcal')
|
|
||||||
|
|
||||||
// Chart 2: Protein vs Lean Mass (only days with both)
|
|
||||||
const protVsLean = filtered.filter(d=>d.protein_g&&d.lean_mass)
|
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}))
|
.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>
|
<div>
|
||||||
<SectionHeader title="🔗 Korrelationen"/>
|
<SectionHeader title="🔗 Korrelationen"/>
|
||||||
|
|
||||||
{/* Chart 1: Kcal vs Weight */}
|
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 12 }}>
|
||||||
<div className="card" style={{marginBottom:12}}>
|
Das Diagramm <strong>Kalorien (Ø 7T) vs. Gewicht</strong> liegt unter <strong>Verlauf → Ernährung</strong> (gleiche Datenbasis).
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
</p>
|
||||||
📉 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>
|
|
||||||
|
|
||||||
{/* Chart 2: Calorie balance */}
|
{/* Chart: Calorie balance */}
|
||||||
<div className="card" style={{marginBottom:12}}>
|
<div className="card" style={{marginBottom:12}}>
|
||||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>
|
||||||
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
⚖️ Kalorienbilanz (Aufnahme − TDEE {tdee} kcal)
|
||||||
|
|
@ -1411,7 +1604,7 @@ export default function History() {
|
||||||
</nav>
|
</nav>
|
||||||
<div className="history-content">
|
<div className="history-content">
|
||||||
{tab==='body' && <BodySection profile={profile} {...sp}/>}
|
{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==='activity' && <ActivitySection activities={activities} globalQualityLevel={activeProfile?.quality_filter_level} {...sp}/>}
|
||||||
{tab==='recovery' && <RecoverySection {...sp}/>}
|
{tab==='recovery' && <RecoverySection {...sp}/>}
|
||||||
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
{tab==='correlation' && <CorrelationSection corrData={corrData} profile={profile} {...sp}/>}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
ResponsiveContainer, CartesianGrid, Legend, ReferenceLine, ScatterChart, Scatter
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { api as nutritionApi } from '../utils/api'
|
import { api as nutritionApi } from '../utils/api'
|
||||||
|
import { MACRO_CHART } from '../utils/macroChartTheme'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||||
dayjs.extend(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}}
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||||
formatter={(v,n)=>[`${Math.round(v)} g`, n]}/>
|
formatter={(v,n)=>[`${Math.round(v)} g`, n]}/>
|
||||||
<Legend wrapperStyle={{fontSize:11}}/>
|
<Legend wrapperStyle={{fontSize:11}}/>
|
||||||
<Bar dataKey="Protein" stackId="a" fill="#1D9E75"/>
|
<Bar dataKey="Protein" stackId="a" fill={MACRO_CHART.protein}/>
|
||||||
<Bar dataKey="Fett" stackId="a" fill="#378ADD"/>
|
<Bar dataKey="Fett" stackId="a" fill={MACRO_CHART.fat}/>
|
||||||
<Bar dataKey="Kohlenhydrate" stackId="a" fill="#D4537E" radius={[3,3,0,0]}/>
|
<Bar dataKey="Kohlenhydrate" stackId="a" fill={MACRO_CHART.carbs} radius={[3,3,0,0]}/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -637,6 +637,8 @@ export const api = {
|
||||||
// Nutrition Charts (E1-E5)
|
// Nutrition Charts (E1-E5)
|
||||||
/** Layer 2b: Verlauf Körper — Charts, Kennzahlen, Bewertung (einheitlich mit Platzhalter-Registry) */
|
/** Layer 2b: Verlauf Körper — Charts, Kennzahlen, Bewertung (einheitlich mit Platzhalter-Registry) */
|
||||||
getBodyHistoryViz: (days=90) => req(`/charts/body-history-viz?days=${days}`),
|
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}`),
|
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
||||||
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
getProteinAdequacyChart: (days=28) => req(`/charts/protein-adequacy?days=${days}`),
|
||||||
getNutritionConsistencyChart: (days=28) => req(`/charts/nutrition-consistency?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