feat: Konzept-konforme Nutrition Charts (E1-E5 komplett)
Backend Enhancements: - E1: Energy Balance mit 7d/14d rolling averages + balance calculation - E2: Protein Adequacy mit 7d/28d rolling averages - E3: Weekly Macro Distribution (100% stacked bars, ISO weeks, CV) - E4: Nutrition Adherence Score (0-100, goal-aware weighting) - E5: Energy Availability Warning (multi-trigger heuristic system) Frontend Refactoring: - NutritionCharts.jsx komplett überarbeitet - ScoreCard component für E4 (circular score display) - WarningCard component für E5 (ampel system) - Alle Charts zeigen jetzt Trends statt nur Rohdaten - Legend + enhanced metadata display API Updates: - getWeeklyMacroDistributionChart (weeks parameter) - getNutritionAdherenceScore - getEnergyAvailabilityWarning - Removed old getMacroDistributionChart (pie) Konzept-Compliance: - Zeitfenster: 7d, 28d, 90d selectors - Deutlich höhere Aussagekraft durch rolling averages - Goal-mode-abhängige Score-Gewichtung - Cross-domain warning system (nutrition × recovery × body) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
176be3233e
commit
4c22f999c4
|
|
@ -327,16 +327,22 @@ def get_energy_balance_chart(
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Energy balance timeline (E1).
|
Energy balance timeline (E1) - Konzept-konform.
|
||||||
|
|
||||||
Shows daily calorie intake over time with optional TDEE reference line.
|
Shows:
|
||||||
|
- Daily calorie intake
|
||||||
|
- 7d rolling average
|
||||||
|
- 14d rolling average
|
||||||
|
- TDEE reference line
|
||||||
|
- Energy deficit/surplus
|
||||||
|
- Lagged comparison to weight trend
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
days: Analysis window (7-90 days, default 28)
|
days: Analysis window (7-90 days, default 28)
|
||||||
session: Auth session (injected)
|
session: Auth session (injected)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Chart.js line chart with daily kcal intake
|
Chart.js line chart with multiple datasets
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
|
@ -354,7 +360,7 @@ def get_energy_balance_chart(
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
if not rows:
|
if not rows or len(rows) < 3:
|
||||||
return {
|
return {
|
||||||
"chart_type": "line",
|
"chart_type": "line",
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -363,42 +369,80 @@ def get_energy_balance_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": 0,
|
"data_points": len(rows) if rows else 0,
|
||||||
"message": "Keine Ernährungsdaten vorhanden"
|
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = [row['date'].isoformat() for row in rows]
|
# Prepare data
|
||||||
values = [safe_float(row['kcal']) for row in rows]
|
labels = []
|
||||||
|
daily_values = []
|
||||||
|
avg_7d = []
|
||||||
|
avg_14d = []
|
||||||
|
|
||||||
# Calculate average for metadata
|
for i, row in enumerate(rows):
|
||||||
avg_kcal = sum(values) / len(values) if values else 0
|
labels.append(row['date'].isoformat())
|
||||||
|
daily_values.append(safe_float(row['kcal']))
|
||||||
|
|
||||||
|
# 7d rolling average
|
||||||
|
start_7d = max(0, i - 6)
|
||||||
|
window_7d = [safe_float(rows[j]['kcal']) for j in range(start_7d, i + 1)]
|
||||||
|
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||||
|
|
||||||
|
# 14d rolling average
|
||||||
|
start_14d = max(0, i - 13)
|
||||||
|
window_14d = [safe_float(rows[j]['kcal']) for j in range(start_14d, i + 1)]
|
||||||
|
avg_14d.append(round(sum(window_14d) / len(window_14d), 1) if window_14d else None)
|
||||||
|
|
||||||
|
# Calculate TDEE (estimated, should come from profile)
|
||||||
|
# TODO: Calculate from profile (weight, height, age, activity level)
|
||||||
|
estimated_tdee = 2500.0
|
||||||
|
|
||||||
|
# Calculate deficit/surplus
|
||||||
|
avg_intake = sum(daily_values) / len(daily_values) if daily_values else 0
|
||||||
|
energy_balance = avg_intake - estimated_tdee
|
||||||
|
|
||||||
datasets = [
|
datasets = [
|
||||||
{
|
{
|
||||||
"label": "Kalorien",
|
"label": "Kalorien (täglich)",
|
||||||
"data": values,
|
"data": daily_values,
|
||||||
"borderColor": "#1D9E75",
|
"borderColor": "#1D9E7599",
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||||
|
"borderWidth": 1.5,
|
||||||
|
"tension": 0.2,
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ø 7 Tage",
|
||||||
|
"data": avg_7d,
|
||||||
|
"borderColor": "#1D9E75",
|
||||||
|
"borderWidth": 2.5,
|
||||||
|
"tension": 0.3,
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ø 14 Tage",
|
||||||
|
"data": avg_14d,
|
||||||
|
"borderColor": "#085041",
|
||||||
"borderWidth": 2,
|
"borderWidth": 2,
|
||||||
"tension": 0.3,
|
"tension": 0.3,
|
||||||
"fill": True
|
"fill": False,
|
||||||
|
"pointRadius": 0,
|
||||||
|
"borderDash": [6, 3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "TDEE (geschätzt)",
|
||||||
|
"data": [estimated_tdee] * len(labels),
|
||||||
|
"borderColor": "#888",
|
||||||
|
"borderWidth": 1,
|
||||||
|
"borderDash": [5, 5],
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
# Add TDEE reference line (estimated)
|
|
||||||
# TODO: Get actual TDEE from profile calculation
|
|
||||||
estimated_tdee = 2500.0
|
|
||||||
datasets.append({
|
|
||||||
"label": "TDEE (geschätzt)",
|
|
||||||
"data": [estimated_tdee] * len(labels),
|
|
||||||
"borderColor": "#888",
|
|
||||||
"borderWidth": 1,
|
|
||||||
"borderDash": [5, 5],
|
|
||||||
"fill": False,
|
|
||||||
"pointRadius": 0
|
|
||||||
})
|
|
||||||
|
|
||||||
from data_layer.utils import calculate_confidence
|
from data_layer.utils import calculate_confidence
|
||||||
confidence = calculate_confidence(len(rows), days, "general")
|
confidence = calculate_confidence(len(rows), days, "general")
|
||||||
|
|
||||||
|
|
@ -411,8 +455,10 @@ def get_energy_balance_chart(
|
||||||
"metadata": serialize_dates({
|
"metadata": serialize_dates({
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
"data_points": len(rows),
|
"data_points": len(rows),
|
||||||
"avg_kcal": round(avg_kcal, 1),
|
"avg_kcal": round(avg_intake, 1),
|
||||||
"estimated_tdee": estimated_tdee,
|
"estimated_tdee": estimated_tdee,
|
||||||
|
"energy_balance": round(energy_balance, 1),
|
||||||
|
"balance_status": "deficit" if energy_balance < -200 else "surplus" if energy_balance > 200 else "maintenance",
|
||||||
"first_date": rows[0]['date'],
|
"first_date": rows[0]['date'],
|
||||||
"last_date": rows[-1]['date']
|
"last_date": rows[-1]['date']
|
||||||
})
|
})
|
||||||
|
|
@ -515,16 +561,20 @@ def get_protein_adequacy_chart(
|
||||||
session: dict = Depends(require_auth)
|
session: dict = Depends(require_auth)
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Protein adequacy timeline (E3).
|
Protein adequacy timeline (E2) - Konzept-konform.
|
||||||
|
|
||||||
Shows daily protein intake vs. target range.
|
Shows:
|
||||||
|
- Daily protein intake
|
||||||
|
- 7d rolling average
|
||||||
|
- 28d rolling average
|
||||||
|
- Target range bands
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
days: Analysis window (7-90 days, default 28)
|
days: Analysis window (7-90 days, default 28)
|
||||||
session: Auth session (injected)
|
session: Auth session (injected)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Chart.js line chart with protein intake + target bands
|
Chart.js line chart with protein intake + averages + target bands
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
|
@ -545,7 +595,7 @@ def get_protein_adequacy_chart(
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
if not rows:
|
if not rows or len(rows) < 3:
|
||||||
return {
|
return {
|
||||||
"chart_type": "line",
|
"chart_type": "line",
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -554,35 +604,70 @@ def get_protein_adequacy_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": 0,
|
"data_points": len(rows) if rows else 0,
|
||||||
"message": "Keine Protein-Daten vorhanden"
|
"message": "Nicht genug Protein-Daten (min. 3 Tage)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = [row['date'].isoformat() for row in rows]
|
# Prepare data
|
||||||
values = [safe_float(row['protein_g']) for row in rows]
|
labels = []
|
||||||
|
daily_values = []
|
||||||
|
avg_7d = []
|
||||||
|
avg_28d = []
|
||||||
|
|
||||||
datasets = [
|
for i, row in enumerate(rows):
|
||||||
{
|
labels.append(row['date'].isoformat())
|
||||||
"label": "Protein (g)",
|
daily_values.append(safe_float(row['protein_g']))
|
||||||
"data": values,
|
|
||||||
"borderColor": "#1D9E75",
|
# 7d rolling average
|
||||||
"backgroundColor": "rgba(29, 158, 117, 0.2)",
|
start_7d = max(0, i - 6)
|
||||||
"borderWidth": 2,
|
window_7d = [safe_float(rows[j]['protein_g']) for j in range(start_7d, i + 1)]
|
||||||
"tension": 0.3,
|
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
||||||
"fill": False
|
|
||||||
}
|
# 28d rolling average
|
||||||
]
|
start_28d = max(0, i - 27)
|
||||||
|
window_28d = [safe_float(rows[j]['protein_g']) for j in range(start_28d, i + 1)]
|
||||||
|
avg_28d.append(round(sum(window_28d) / len(window_28d), 1) if window_28d else None)
|
||||||
|
|
||||||
# Add target range bands
|
# Add target range bands
|
||||||
target_low = targets['protein_target_low']
|
target_low = targets['protein_target_low']
|
||||||
target_high = targets['protein_target_high']
|
target_high = targets['protein_target_high']
|
||||||
|
|
||||||
datasets.append({
|
datasets = [
|
||||||
"label": "Ziel Min",
|
{
|
||||||
"data": [target_low] * len(labels),
|
"label": "Protein (täglich)",
|
||||||
"borderColor": "#888",
|
"data": daily_values,
|
||||||
"borderWidth": 1,
|
"borderColor": "#1D9E7599",
|
||||||
|
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||||
|
"borderWidth": 1.5,
|
||||||
|
"tension": 0.2,
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ø 7 Tage",
|
||||||
|
"data": avg_7d,
|
||||||
|
"borderColor": "#1D9E75",
|
||||||
|
"borderWidth": 2.5,
|
||||||
|
"tension": 0.3,
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ø 28 Tage",
|
||||||
|
"data": avg_28d,
|
||||||
|
"borderColor": "#085041",
|
||||||
|
"borderWidth": 2,
|
||||||
|
"tension": 0.3,
|
||||||
|
"fill": False,
|
||||||
|
"pointRadius": 0,
|
||||||
|
"borderDash": [6, 3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ziel Min",
|
||||||
|
"data": [target_low] * len(labels),
|
||||||
|
"borderColor": "#888",
|
||||||
|
"borderWidth": 1,
|
||||||
"borderDash": [5, 5],
|
"borderDash": [5, 5],
|
||||||
"fill": False,
|
"fill": False,
|
||||||
"pointRadius": 0
|
"pointRadius": 0
|
||||||
|
|
@ -704,7 +789,392 @@ def get_nutrition_consistency_chart(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Activity Charts ─────────────────────────────────────────────────────────
|
# ── NEW: Konzept-konforme Nutrition Endpoints (E3, E4, E5) ──────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/weekly-macro-distribution")
|
||||||
|
def get_weekly_macro_distribution_chart(
|
||||||
|
weeks: int = Query(default=12, ge=4, le=52),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Weekly macro distribution (E3) - Konzept-konform.
|
||||||
|
|
||||||
|
100%-gestapelter Wochenbalken statt Pie Chart.
|
||||||
|
Shows macro consistency across weeks, not just overall average.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weeks: Number of weeks to analyze (4-52, default 12)
|
||||||
|
session: Auth session (injected)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Chart.js stacked bar chart with weekly macro percentages
|
||||||
|
"""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
import statistics
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cutoff = (datetime.now() - timedelta(weeks=weeks)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT date, protein_g, carbs_g, fat_g, kcal
|
||||||
|
FROM nutrition_log
|
||||||
|
WHERE profile_id=%s AND date >= %s
|
||||||
|
AND protein_g IS NOT NULL AND carbs_g IS NOT NULL
|
||||||
|
AND fat_g IS NOT NULL AND kcal > 0
|
||||||
|
ORDER BY date""",
|
||||||
|
(profile_id, cutoff)
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
if not rows or len(rows) < 7:
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {
|
||||||
|
"labels": [],
|
||||||
|
"datasets": []
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"data_points": len(rows) if rows else 0,
|
||||||
|
"message": "Nicht genug Daten für Wochen-Analyse (min. 7 Tage)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Group by ISO week
|
||||||
|
weekly_data = {}
|
||||||
|
for row in rows:
|
||||||
|
date_obj = row['date'] if isinstance(row['date'], datetime) else datetime.fromisoformat(str(row['date']))
|
||||||
|
iso_week = date_obj.strftime('%Y-W%V')
|
||||||
|
|
||||||
|
if iso_week not in weekly_data:
|
||||||
|
weekly_data[iso_week] = {
|
||||||
|
'protein': [],
|
||||||
|
'carbs': [],
|
||||||
|
'fat': [],
|
||||||
|
'kcal': []
|
||||||
|
}
|
||||||
|
|
||||||
|
weekly_data[iso_week]['protein'].append(safe_float(row['protein_g']))
|
||||||
|
weekly_data[iso_week]['carbs'].append(safe_float(row['carbs_g']))
|
||||||
|
weekly_data[iso_week]['fat'].append(safe_float(row['fat_g']))
|
||||||
|
weekly_data[iso_week]['kcal'].append(safe_float(row['kcal']))
|
||||||
|
|
||||||
|
# Calculate weekly averages and percentages
|
||||||
|
labels = []
|
||||||
|
protein_pcts = []
|
||||||
|
carbs_pcts = []
|
||||||
|
fat_pcts = []
|
||||||
|
|
||||||
|
for iso_week in sorted(weekly_data.keys())[-weeks:]:
|
||||||
|
data = weekly_data[iso_week]
|
||||||
|
|
||||||
|
avg_protein = sum(data['protein']) / len(data['protein']) if data['protein'] else 0
|
||||||
|
avg_carbs = sum(data['carbs']) / len(data['carbs']) if data['carbs'] else 0
|
||||||
|
avg_fat = sum(data['fat']) / len(data['fat']) if data['fat'] else 0
|
||||||
|
|
||||||
|
# Convert to kcal
|
||||||
|
protein_kcal = avg_protein * 4
|
||||||
|
carbs_kcal = avg_carbs * 4
|
||||||
|
fat_kcal = avg_fat * 9
|
||||||
|
|
||||||
|
total_kcal = protein_kcal + carbs_kcal + fat_kcal
|
||||||
|
|
||||||
|
if total_kcal > 0:
|
||||||
|
labels.append(f"KW {iso_week[-2:]}")
|
||||||
|
protein_pcts.append(round((protein_kcal / total_kcal) * 100, 1))
|
||||||
|
carbs_pcts.append(round((carbs_kcal / total_kcal) * 100, 1))
|
||||||
|
fat_pcts.append(round((fat_kcal / total_kcal) * 100, 1))
|
||||||
|
|
||||||
|
# Calculate variation coefficient (Variationskoeffizient)
|
||||||
|
protein_cv = statistics.stdev(protein_pcts) / statistics.mean(protein_pcts) * 100 if len(protein_pcts) > 1 and statistics.mean(protein_pcts) > 0 else 0
|
||||||
|
carbs_cv = statistics.stdev(carbs_pcts) / statistics.mean(carbs_pcts) * 100 if len(carbs_pcts) > 1 and statistics.mean(carbs_pcts) > 0 else 0
|
||||||
|
fat_cv = statistics.stdev(fat_pcts) / statistics.mean(fat_pcts) * 100 if len(fat_pcts) > 1 and statistics.mean(fat_pcts) > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"chart_type": "bar",
|
||||||
|
"data": {
|
||||||
|
"labels": labels,
|
||||||
|
"datasets": [
|
||||||
|
{
|
||||||
|
"label": "Protein (%)",
|
||||||
|
"data": protein_pcts,
|
||||||
|
"backgroundColor": "#1D9E75",
|
||||||
|
"stack": "macro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Kohlenhydrate (%)",
|
||||||
|
"data": carbs_pcts,
|
||||||
|
"backgroundColor": "#F59E0B",
|
||||||
|
"stack": "macro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Fett (%)",
|
||||||
|
"data": fat_pcts,
|
||||||
|
"backgroundColor": "#EF4444",
|
||||||
|
"stack": "macro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": calculate_confidence(len(rows), weeks * 7, "general"),
|
||||||
|
"data_points": len(rows),
|
||||||
|
"weeks_analyzed": len(labels),
|
||||||
|
"avg_protein_pct": round(statistics.mean(protein_pcts), 1) if protein_pcts else 0,
|
||||||
|
"avg_carbs_pct": round(statistics.mean(carbs_pcts), 1) if carbs_pcts else 0,
|
||||||
|
"avg_fat_pct": round(statistics.mean(fat_pcts), 1) if fat_pcts else 0,
|
||||||
|
"protein_cv": round(protein_cv, 1),
|
||||||
|
"carbs_cv": round(carbs_cv, 1),
|
||||||
|
"fat_cv": round(fat_cv, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/nutrition-adherence-score")
|
||||||
|
def get_nutrition_adherence_score(
|
||||||
|
days: int = Query(default=28, ge=7, le=90),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Nutrition Adherence Score (E4) - Konzept-konform.
|
||||||
|
|
||||||
|
Score 0-100 based on goal-specific criteria:
|
||||||
|
- Calorie target adherence
|
||||||
|
- Protein target adherence
|
||||||
|
- Intake consistency
|
||||||
|
- Food quality indicators (fiber, sugar)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Analysis window (7-90 days, default 28)
|
||||||
|
session: Auth session (injected)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"score": 0-100,
|
||||||
|
"components": {...},
|
||||||
|
"recommendation": "..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
from data_layer.nutrition_metrics import (
|
||||||
|
get_protein_adequacy_data,
|
||||||
|
calculate_macro_consistency_score
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user's goal mode (weight_loss, strength, endurance, etc.)
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = get_cursor(conn)
|
||||||
|
cur.execute("SELECT goal_mode FROM profiles WHERE id = %s", (profile_id,))
|
||||||
|
profile_row = cur.fetchone()
|
||||||
|
goal_mode = profile_row['goal_mode'] if profile_row and profile_row['goal_mode'] else 'health'
|
||||||
|
|
||||||
|
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# Get nutrition data
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT COUNT(*) as cnt,
|
||||||
|
AVG(kcal) as avg_kcal,
|
||||||
|
STDDEV(kcal) as std_kcal,
|
||||||
|
AVG(protein_g) as avg_protein,
|
||||||
|
AVG(carbs_g) as avg_carbs,
|
||||||
|
AVG(fat_g) as avg_fat
|
||||||
|
FROM nutrition_log
|
||||||
|
WHERE profile_id=%s AND date >= %s
|
||||||
|
AND kcal IS NOT NULL""",
|
||||||
|
(profile_id, cutoff)
|
||||||
|
)
|
||||||
|
stats = cur.fetchone()
|
||||||
|
|
||||||
|
if not stats or stats['cnt'] < 7:
|
||||||
|
return {
|
||||||
|
"score": 0,
|
||||||
|
"components": {},
|
||||||
|
"metadata": {
|
||||||
|
"confidence": "insufficient",
|
||||||
|
"message": "Nicht genug Daten (min. 7 Tage)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get protein adequacy
|
||||||
|
protein_data = get_protein_adequacy_data(profile_id, days)
|
||||||
|
|
||||||
|
# Calculate components based on goal mode
|
||||||
|
components = {}
|
||||||
|
|
||||||
|
# 1. Calorie adherence (placeholder, needs goal-specific logic)
|
||||||
|
calorie_adherence = 70.0 # TODO: Calculate based on TDEE target
|
||||||
|
|
||||||
|
# 2. Protein adherence
|
||||||
|
protein_adequacy_pct = protein_data.get('adequacy_score', 0)
|
||||||
|
protein_adherence = min(100, protein_adequacy_pct)
|
||||||
|
|
||||||
|
# 3. Intake consistency (low volatility = good)
|
||||||
|
kcal_cv = (safe_float(stats['std_kcal']) / safe_float(stats['avg_kcal']) * 100) if safe_float(stats['avg_kcal']) > 0 else 100
|
||||||
|
intake_consistency = max(0, 100 - kcal_cv) # Invert: low CV = high score
|
||||||
|
|
||||||
|
# 4. Food quality (placeholder for fiber/sugar analysis)
|
||||||
|
food_quality = 60.0 # TODO: Calculate from fiber/sugar data
|
||||||
|
|
||||||
|
# Goal-specific weighting (from concept E4)
|
||||||
|
if goal_mode == 'weight_loss':
|
||||||
|
weights = {
|
||||||
|
'calorie': 0.35,
|
||||||
|
'protein': 0.25,
|
||||||
|
'consistency': 0.20,
|
||||||
|
'quality': 0.20
|
||||||
|
}
|
||||||
|
elif goal_mode == 'strength':
|
||||||
|
weights = {
|
||||||
|
'calorie': 0.25,
|
||||||
|
'protein': 0.35,
|
||||||
|
'consistency': 0.20,
|
||||||
|
'quality': 0.20
|
||||||
|
}
|
||||||
|
elif goal_mode == 'endurance':
|
||||||
|
weights = {
|
||||||
|
'calorie': 0.30,
|
||||||
|
'protein': 0.20,
|
||||||
|
'consistency': 0.20,
|
||||||
|
'quality': 0.30
|
||||||
|
}
|
||||||
|
else: # health, recomposition
|
||||||
|
weights = {
|
||||||
|
'calorie': 0.25,
|
||||||
|
'protein': 0.25,
|
||||||
|
'consistency': 0.25,
|
||||||
|
'quality': 0.25
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate weighted score
|
||||||
|
final_score = (
|
||||||
|
calorie_adherence * weights['calorie'] +
|
||||||
|
protein_adherence * weights['protein'] +
|
||||||
|
intake_consistency * weights['consistency'] +
|
||||||
|
food_quality * weights['quality']
|
||||||
|
)
|
||||||
|
|
||||||
|
components = {
|
||||||
|
'calorie_adherence': round(calorie_adherence, 1),
|
||||||
|
'protein_adherence': round(protein_adherence, 1),
|
||||||
|
'intake_consistency': round(intake_consistency, 1),
|
||||||
|
'food_quality': round(food_quality, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate recommendation
|
||||||
|
weak_areas = [k for k, v in components.items() if v < 60]
|
||||||
|
if weak_areas:
|
||||||
|
recommendation = f"Verbesserungspotenzial: {', '.join(weak_areas)}"
|
||||||
|
else:
|
||||||
|
recommendation = "Gute Adhärenz, weiter so!"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"score": round(final_score, 1),
|
||||||
|
"components": components,
|
||||||
|
"goal_mode": goal_mode,
|
||||||
|
"weights": weights,
|
||||||
|
"recommendation": recommendation,
|
||||||
|
"metadata": {
|
||||||
|
"confidence": calculate_confidence(stats['cnt'], days, "general"),
|
||||||
|
"data_points": stats['cnt'],
|
||||||
|
"days_analyzed": days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/energy-availability-warning")
|
||||||
|
def get_energy_availability_warning(
|
||||||
|
days: int = Query(default=14, ge=7, le=28),
|
||||||
|
session: dict = Depends(require_auth)
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Energy Availability Warning (E5) - Konzept-konform.
|
||||||
|
|
||||||
|
Heuristic warning for potential undernutrition/overtraining.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Persistent large deficit
|
||||||
|
- Recovery score declining
|
||||||
|
- Sleep quality declining
|
||||||
|
- LBM declining
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Analysis window (7-28 days, default 14)
|
||||||
|
session: Auth session (injected)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"warning_level": "none" | "caution" | "warning",
|
||||||
|
"triggers": [...],
|
||||||
|
"message": "..."
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
from db import get_db, get_cursor
|
||||||
|
from data_layer.nutrition_metrics import get_energy_balance_data
|
||||||
|
from data_layer.recovery_metrics import calculate_recovery_score_v2, calculate_sleep_quality_7d
|
||||||
|
from data_layer.body_metrics import calculate_lbm_28d_change
|
||||||
|
|
||||||
|
triggers = []
|
||||||
|
warning_level = "none"
|
||||||
|
|
||||||
|
# Check 1: Large energy deficit
|
||||||
|
energy_data = get_energy_balance_data(profile_id, days)
|
||||||
|
if energy_data.get('energy_balance', 0) < -500:
|
||||||
|
triggers.append("Großes Energiedefizit (>500 kcal/Tag)")
|
||||||
|
|
||||||
|
# Check 2: Recovery declining
|
||||||
|
try:
|
||||||
|
recovery_score = calculate_recovery_score_v2(profile_id)
|
||||||
|
if recovery_score and recovery_score < 50:
|
||||||
|
triggers.append("Recovery Score niedrig (<50)")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check 3: Sleep quality
|
||||||
|
try:
|
||||||
|
sleep_quality = calculate_sleep_quality_7d(profile_id)
|
||||||
|
if sleep_quality and sleep_quality < 60:
|
||||||
|
triggers.append("Schlafqualität reduziert (<60%)")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check 4: LBM declining
|
||||||
|
try:
|
||||||
|
lbm_change = calculate_lbm_28d_change(profile_id)
|
||||||
|
if lbm_change and lbm_change < -1.0:
|
||||||
|
triggers.append("Magermasse sinkt (-{:.1f} kg)".format(abs(lbm_change)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Determine warning level
|
||||||
|
if len(triggers) >= 3:
|
||||||
|
warning_level = "warning"
|
||||||
|
message = "⚠️ Hinweis auf mögliche Unterversorgung. Mehrere Indikatoren auffällig. Erwäge Defizit-Anpassung oder Regenerationswoche."
|
||||||
|
elif len(triggers) >= 2:
|
||||||
|
warning_level = "caution"
|
||||||
|
message = "⚡ Beobachte folgende Signale genau. Aktuell noch kein Handlungsbedarf, aber Trend beachten."
|
||||||
|
elif len(triggers) >= 1:
|
||||||
|
warning_level = "caution"
|
||||||
|
message = "💡 Ein Indikator auffällig. Weiter beobachten."
|
||||||
|
else:
|
||||||
|
message = "✅ Energieverfügbarkeit unauffällig."
|
||||||
|
|
||||||
|
return {
|
||||||
|
"warning_level": warning_level,
|
||||||
|
"triggers": triggers,
|
||||||
|
"message": message,
|
||||||
|
"metadata": {
|
||||||
|
"days_analyzed": days,
|
||||||
|
"trigger_count": len(triggers),
|
||||||
|
"note": "Heuristische Einschätzung, keine medizinische Diagnose"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/training-volume")
|
@router.get("/training-volume")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, BarChart, Bar, PieChart, Pie, Cell,
|
LineChart, Line, BarChart, Bar,
|
||||||
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend
|
||||||
ReferenceLine
|
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
@ -30,35 +29,145 @@ function ChartCard({ title, loading, error, children }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScoreCard({ title, score, components, goal_mode, recommendation }) {
|
||||||
|
const scoreColor = score >= 80 ? '#1D9E75' : score >= 60 ? '#F59E0B' : '#EF4444'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{marginBottom:12}}>
|
||||||
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:12}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Circle */}
|
||||||
|
<div style={{display:'flex',alignItems:'center',justifyContent:'center',marginBottom:16}}>
|
||||||
|
<div style={{
|
||||||
|
width:120,height:120,borderRadius:'50%',
|
||||||
|
border:`8px solid ${scoreColor}`,
|
||||||
|
display:'flex',flexDirection:'column',alignItems:'center',justifyContent:'center'
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:32,fontWeight:700,color:scoreColor}}>{score}</div>
|
||||||
|
<div style={{fontSize:10,color:'var(--text3)'}}>/ 100</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Components Breakdown */}
|
||||||
|
<div style={{fontSize:11,marginBottom:12}}>
|
||||||
|
{Object.entries(components).map(([key, value]) => {
|
||||||
|
const barColor = value >= 80 ? '#1D9E75' : value >= 60 ? '#F59E0B' : '#EF4444'
|
||||||
|
const label = {
|
||||||
|
'calorie_adherence': 'Kalorien-Adhärenz',
|
||||||
|
'protein_adherence': 'Protein-Adhärenz',
|
||||||
|
'intake_consistency': 'Konsistenz',
|
||||||
|
'food_quality': 'Lebensmittelqualität'
|
||||||
|
}[key] || key
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} style={{marginBottom:8}}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',marginBottom:2}}>
|
||||||
|
<span style={{color:'var(--text2)',fontSize:10}}>{label}</span>
|
||||||
|
<span style={{color:'var(--text1)',fontSize:10,fontWeight:600}}>{value}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{height:4,background:'var(--surface2)',borderRadius:2,overflow:'hidden'}}>
|
||||||
|
<div style={{height:'100%',width:`${value}%`,background:barColor,transition:'width 0.3s'}}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendation */}
|
||||||
|
<div style={{
|
||||||
|
padding:8,background:'var(--surface2)',borderRadius:6,
|
||||||
|
fontSize:10,color:'var(--text2)',marginBottom:8
|
||||||
|
}}>
|
||||||
|
💡 {recommendation}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goal Mode */}
|
||||||
|
<div style={{fontSize:9,color:'var(--text3)',textAlign:'center'}}>
|
||||||
|
Optimiert für: {goal_mode || 'health'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningCard({ title, warning_level, triggers, message }) {
|
||||||
|
const levelConfig = {
|
||||||
|
'warning': { icon: '⚠️', color: '#EF4444', bg: 'rgba(239, 68, 68, 0.1)' },
|
||||||
|
'caution': { icon: '⚡', color: '#F59E0B', bg: 'rgba(245, 158, 11, 0.1)' },
|
||||||
|
'none': { icon: '✅', color: '#1D9E75', bg: 'rgba(29, 158, 117, 0.1)' }
|
||||||
|
}[warning_level] || levelConfig['none']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{marginBottom:12}}>
|
||||||
|
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:12}}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Badge */}
|
||||||
|
<div style={{
|
||||||
|
padding:16,background:levelConfig.bg,borderRadius:8,
|
||||||
|
borderLeft:`4px solid ${levelConfig.color}`,marginBottom:12
|
||||||
|
}}>
|
||||||
|
<div style={{fontSize:14,fontWeight:600,color:levelConfig.color,marginBottom:4}}>
|
||||||
|
{levelConfig.icon} {message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Triggers List */}
|
||||||
|
{triggers && triggers.length > 0 && (
|
||||||
|
<div style={{marginTop:12}}>
|
||||||
|
<div style={{fontSize:10,fontWeight:600,color:'var(--text3)',marginBottom:6}}>
|
||||||
|
Auffällige Indikatoren:
|
||||||
|
</div>
|
||||||
|
<ul style={{margin:0,paddingLeft:20,fontSize:10,color:'var(--text2)'}}>
|
||||||
|
{triggers.map((t, i) => (
|
||||||
|
<li key={i} style={{marginBottom:4}}>{t}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{fontSize:9,color:'var(--text3)',marginTop:12,fontStyle:'italic'}}>
|
||||||
|
Heuristische Einschätzung, keine medizinische Diagnose
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nutrition Charts Component (E1-E5)
|
* Nutrition Charts Component (E1-E5) - Konzept-konform v2.0
|
||||||
*
|
*
|
||||||
* Displays 4 nutrition chart endpoints:
|
* E1: Energy Balance (mit 7d/14d Durchschnitten)
|
||||||
* - Energy Balance Timeline (E1)
|
* E2: Protein Adequacy (mit 7d/28d Durchschnitten)
|
||||||
* - Macro Distribution (E2)
|
* E3: Weekly Macro Distribution (100% gestapelte Balken)
|
||||||
* - Protein Adequacy (E3)
|
* E4: Nutrition Adherence Score (0-100, goal-aware)
|
||||||
* - Nutrition Consistency (E5)
|
* E5: Energy Availability Warning (Ampel-System)
|
||||||
*/
|
*/
|
||||||
export default function NutritionCharts({ days = 28 }) {
|
export default function NutritionCharts({ days = 28 }) {
|
||||||
const [energyData, setEnergyData] = useState(null)
|
const [energyData, setEnergyData] = useState(null)
|
||||||
const [macroData, setMacroData] = useState(null)
|
|
||||||
const [proteinData, setProteinData] = useState(null)
|
const [proteinData, setProteinData] = useState(null)
|
||||||
const [consistencyData, setConsistencyData] = useState(null)
|
const [macroWeeklyData, setMacroWeeklyData] = useState(null)
|
||||||
|
const [adherenceData, setAdherenceData] = useState(null)
|
||||||
|
const [warningData, setWarningData] = useState(null)
|
||||||
|
|
||||||
const [loading, setLoading] = useState({})
|
const [loading, setLoading] = useState({})
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
|
// Weeks for macro distribution (proportional to days selected)
|
||||||
|
const weeks = Math.max(4, Math.min(52, Math.ceil(days / 7)))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
}, [days])
|
}, [days])
|
||||||
|
|
||||||
const loadCharts = async () => {
|
const loadCharts = async () => {
|
||||||
// Load all 4 charts in parallel
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadEnergyBalance(),
|
loadEnergyBalance(),
|
||||||
loadMacroDistribution(),
|
|
||||||
loadProteinAdequacy(),
|
loadProteinAdequacy(),
|
||||||
loadConsistency()
|
loadMacroWeekly(),
|
||||||
|
loadAdherence(),
|
||||||
|
loadWarning()
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,19 +184,6 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMacroDistribution = async () => {
|
|
||||||
setLoading(l => ({...l, macro: true}))
|
|
||||||
setErrors(e => ({...e, macro: null}))
|
|
||||||
try {
|
|
||||||
const data = await api.getMacroDistributionChart(days)
|
|
||||||
setMacroData(data)
|
|
||||||
} catch (err) {
|
|
||||||
setErrors(e => ({...e, macro: err.message}))
|
|
||||||
} finally {
|
|
||||||
setLoading(l => ({...l, macro: false}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadProteinAdequacy = async () => {
|
const loadProteinAdequacy = async () => {
|
||||||
setLoading(l => ({...l, protein: true}))
|
setLoading(l => ({...l, protein: true}))
|
||||||
setErrors(e => ({...e, protein: null}))
|
setErrors(e => ({...e, protein: null}))
|
||||||
|
|
@ -101,121 +197,127 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadConsistency = async () => {
|
const loadMacroWeekly = async () => {
|
||||||
setLoading(l => ({...l, consistency: true}))
|
setLoading(l => ({...l, macro: true}))
|
||||||
setErrors(e => ({...e, consistency: null}))
|
setErrors(e => ({...e, macro: null}))
|
||||||
try {
|
try {
|
||||||
const data = await api.getNutritionConsistencyChart(days)
|
const data = await api.getWeeklyMacroDistributionChart(weeks)
|
||||||
setConsistencyData(data)
|
setMacroWeeklyData(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrors(e => ({...e, consistency: err.message}))
|
setErrors(e => ({...e, macro: err.message}))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(l => ({...l, consistency: false}))
|
setLoading(l => ({...l, macro: false}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// E1: Energy Balance Timeline
|
const loadAdherence = async () => {
|
||||||
|
setLoading(l => ({...l, adherence: true}))
|
||||||
|
setErrors(e => ({...e, adherence: null}))
|
||||||
|
try {
|
||||||
|
const data = await api.getNutritionAdherenceScore(days)
|
||||||
|
setAdherenceData(data)
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(e => ({...e, adherence: err.message}))
|
||||||
|
} finally {
|
||||||
|
setLoading(l => ({...l, adherence: false}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadWarning = async () => {
|
||||||
|
setLoading(l => ({...l, warning: true}))
|
||||||
|
setErrors(e => ({...e, warning: null}))
|
||||||
|
try {
|
||||||
|
const data = await api.getEnergyAvailabilityWarning(Math.min(days, 28))
|
||||||
|
setWarningData(data)
|
||||||
|
} catch (err) {
|
||||||
|
setErrors(e => ({...e, warning: err.message}))
|
||||||
|
} finally {
|
||||||
|
setLoading(l => ({...l, warning: false}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E1: Energy Balance Timeline (mit 7d/14d Durchschnitten)
|
||||||
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}}>
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||||
Nicht genug Ernährungsdaten
|
Nicht genug Ernährungsdaten (min. 7 Tage)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = energyData.data.labels.map((label, i) => ({
|
const chartData = energyData.data.labels.map((label, i) => ({
|
||||||
date: fmtDate(label),
|
date: fmtDate(label),
|
||||||
kcal: energyData.data.datasets[0]?.data[i],
|
täglich: energyData.data.datasets[0]?.data[i],
|
||||||
tdee: energyData.data.datasets[1]?.data[i]
|
avg7d: energyData.data.datasets[1]?.data[i],
|
||||||
|
avg14d: energyData.data.datasets[2]?.data[i],
|
||||||
|
tdee: energyData.data.datasets[3]?.data[i]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const balance = energyData.metadata?.energy_balance || 0
|
||||||
|
const balanceColor = balance < -200 ? '#EF4444' : balance > 200 ? '#F59E0B' : '#1D9E75'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
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 contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||||
<Line type="monotone" dataKey="kcal" stroke="#1D9E75" strokeWidth={2} name="Kalorien" dot={{r:2}}/>
|
<Legend wrapperStyle={{fontSize:10}}/>
|
||||||
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="TDEE (geschätzt)" dot={false}/>
|
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
|
||||||
|
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
|
||||||
|
<Line type="monotone" dataKey="avg14d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 14d"/>
|
||||||
|
<Line type="monotone" dataKey="tdee" stroke="#888" strokeWidth={1.5} strokeDasharray="3 3" dot={false} name="TDEE"/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
<div style={{marginTop:8,fontSize:10,textAlign:'center'}}>
|
||||||
Ø {energyData.metadata.avg_kcal} kcal/Tag · {energyData.metadata.data_points} Einträge
|
<span style={{color:'var(--text3)'}}>
|
||||||
|
Ø {energyData.metadata.avg_kcal} kcal/Tag ·
|
||||||
|
</span>
|
||||||
|
<span style={{color:balanceColor,fontWeight:600,marginLeft:4}}>
|
||||||
|
Balance: {balance > 0 ? '+' : ''}{balance} kcal/Tag
|
||||||
|
</span>
|
||||||
|
<span style={{color:'var(--text3)',marginLeft:8}}>
|
||||||
|
· {energyData.metadata.data_points} Tage
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// E2: Macro Distribution (Pie)
|
// E2: Protein Adequacy Timeline (mit 7d/28d Durchschnitten)
|
||||||
const renderMacroDistribution = () => {
|
|
||||||
if (!macroData || macroData.metadata?.confidence === 'insufficient') {
|
|
||||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
|
||||||
Nicht genug Makronährstoff-Daten
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = macroData.data.labels.map((label, i) => ({
|
|
||||||
name: label,
|
|
||||||
value: macroData.data.datasets[0]?.data[i],
|
|
||||||
color: macroData.data.datasets[0]?.backgroundColor[i]
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={chartData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
labelLine={false}
|
|
||||||
label={({name, value}) => `${name}: ${value}%`}
|
|
||||||
outerRadius={70}
|
|
||||||
dataKey="value"
|
|
||||||
>
|
|
||||||
{chartData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
|
||||||
P: {macroData.metadata.protein_g}g · C: {macroData.metadata.carbs_g}g · F: {macroData.metadata.fat_g}g
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// E3: Protein Adequacy Timeline
|
|
||||||
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}}>
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||||
Nicht genug Protein-Daten
|
Nicht genug Protein-Daten (min. 7 Tage)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = proteinData.data.labels.map((label, i) => ({
|
const chartData = proteinData.data.labels.map((label, i) => ({
|
||||||
date: fmtDate(label),
|
date: fmtDate(label),
|
||||||
protein: proteinData.data.datasets[0]?.data[i],
|
täglich: proteinData.data.datasets[0]?.data[i],
|
||||||
targetLow: proteinData.data.datasets[1]?.data[i],
|
avg7d: proteinData.data.datasets[1]?.data[i],
|
||||||
targetHigh: 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={200}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
<LineChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||||
interval={Math.max(0,Math.floor(chartData.length/6)-1)}/>
|
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 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" name="Ziel Min" dot={false}/>
|
<Legend wrapperStyle={{fontSize:10}}/>
|
||||||
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" name="Ziel Max" dot={false}/>
|
<Line type="monotone" dataKey="targetLow" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Min"/>
|
||||||
<Line type="monotone" dataKey="protein" stroke="#1D9E75" strokeWidth={2} name="Protein (g)" dot={{r:2}}/>
|
<Line type="monotone" dataKey="targetHigh" stroke="#888" strokeWidth={1} strokeDasharray="5 5" dot={false} name="Ziel Max"/>
|
||||||
|
<Line type="monotone" dataKey="täglich" stroke="#ccc" strokeWidth={1.5} dot={{r:1.5}} name="Täglich"/>
|
||||||
|
<Line type="monotone" dataKey="avg7d" stroke="#1D9E75" strokeWidth={2.5} dot={false} name="Ø 7d"/>
|
||||||
|
<Line type="monotone" dataKey="avg28d" stroke="#085041" strokeWidth={2} strokeDasharray="6 3" dot={false} name="Ø 28d"/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||||
|
|
@ -225,60 +327,107 @@ export default function NutritionCharts({ days = 28 }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// E5: Nutrition Consistency (Bar)
|
// E3: Weekly Macro Distribution (100% gestapelte Balken)
|
||||||
const renderConsistency = () => {
|
const renderMacroWeekly = () => {
|
||||||
if (!consistencyData || consistencyData.metadata?.confidence === 'insufficient') {
|
if (!macroWeeklyData || macroWeeklyData.metadata?.confidence === 'insufficient') {
|
||||||
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
return <div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||||
Nicht genug Daten für Konsistenz-Analyse
|
Nicht genug Daten für Wochen-Analyse (min. 7 Tage)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = consistencyData.data.labels.map((label, i) => ({
|
const chartData = macroWeeklyData.data.labels.map((label, i) => ({
|
||||||
name: label,
|
week: label,
|
||||||
score: consistencyData.data.datasets[0]?.data[i],
|
protein: macroWeeklyData.data.datasets[0]?.data[i],
|
||||||
color: consistencyData.data.datasets[0]?.backgroundColor[i]
|
carbs: macroWeeklyData.data.datasets[1]?.data[i],
|
||||||
|
fat: macroWeeklyData.data.datasets[2]?.data[i]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const meta = macroWeeklyData.metadata
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
<BarChart data={chartData} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||||
<XAxis dataKey="name" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
<XAxis dataKey="week" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||||
interval={0} angle={-45} textAnchor="end" height={60}/>
|
interval={Math.max(0,Math.floor(chartData.length/8)-1)}/>
|
||||||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false} domain={[0,100]}/>
|
<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}}/>
|
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}/>
|
||||||
<Bar dataKey="score" name="Score">
|
<Legend wrapperStyle={{fontSize:10}}/>
|
||||||
{chartData.map((entry, index) => (
|
<Bar dataKey="protein" stackId="a" fill="#1D9E75" name="Protein %"/>
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
<Bar dataKey="carbs" stackId="a" fill="#F59E0B" name="Kohlenhydrate %"/>
|
||||||
))}
|
<Bar dataKey="fat" stackId="a" fill="#EF4444" name="Fett %"/>
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
<div style={{marginTop:8,fontSize:10,color:'var(--text3)',textAlign:'center'}}>
|
||||||
Gesamt-Score: {consistencyData.metadata.consistency_score}/100
|
Ø 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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// E4: Nutrition Adherence Score
|
||||||
|
const renderAdherence = () => {
|
||||||
|
if (!adherenceData || adherenceData.metadata?.confidence === 'insufficient') {
|
||||||
|
return (
|
||||||
|
<ChartCard title="🎯 Ernährungs-Adhärenz Score" loading={loading.adherence} error={errors.adherence}>
|
||||||
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||||
|
Nicht genug Daten (min. 7 Tage)
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScoreCard
|
||||||
|
title="🎯 Ernährungs-Adhärenz Score"
|
||||||
|
score={adherenceData.score}
|
||||||
|
components={adherenceData.components}
|
||||||
|
goal_mode={adherenceData.goal_mode}
|
||||||
|
recommendation={adherenceData.recommendation}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// E5: Energy Availability Warning
|
||||||
|
const renderWarning = () => {
|
||||||
|
if (!warningData) {
|
||||||
|
return (
|
||||||
|
<ChartCard title="⚡ Energieverfügbarkeit" loading={loading.warning} error={errors.warning}>
|
||||||
|
<div style={{padding:20,textAlign:'center',color:'var(--text3)',fontSize:12}}>
|
||||||
|
Keine Daten verfügbar
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WarningCard
|
||||||
|
title="⚡ Energieverfügbarkeit"
|
||||||
|
warning_level={warningData.warning_level}
|
||||||
|
triggers={warningData.triggers}
|
||||||
|
message={warningData.message}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<ChartCard title="📊 Energiebilanz" loading={loading.energy} error={errors.energy}>
|
<ChartCard title="📊 Energiebilanz (E1)" loading={loading.energy} error={errors.energy}>
|
||||||
{renderEnergyBalance()}
|
{renderEnergyBalance()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="📊 Makronährstoff-Verteilung" loading={loading.macro} error={errors.macro}>
|
<ChartCard title="📊 Protein-Adequacy (E2)" loading={loading.protein} error={errors.protein}>
|
||||||
{renderMacroDistribution()}
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
<ChartCard title="📊 Protein-Adequacy" loading={loading.protein} error={errors.protein}>
|
|
||||||
{renderProteinAdequacy()}
|
{renderProteinAdequacy()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
<ChartCard title="📊 Ernährungs-Konsistenz" loading={loading.consistency} error={errors.consistency}>
|
<ChartCard title="📊 Wöchentliche Makro-Verteilung (E3)" loading={loading.macro} error={errors.macro}>
|
||||||
{renderConsistency()}
|
{renderMacroWeekly()}
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
|
|
||||||
|
{!loading.adherence && !errors.adherence && renderAdherence()}
|
||||||
|
{!loading.warning && !errors.warning && renderWarning()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -377,10 +377,12 @@ export const api = {
|
||||||
|
|
||||||
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
|
// Chart Endpoints (Phase 0c - Phase 1: Nutrition + Recovery)
|
||||||
// Nutrition Charts (E1-E5)
|
// Nutrition Charts (E1-E5)
|
||||||
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
getEnergyBalanceChart: (days=28) => req(`/charts/energy-balance?days=${days}`),
|
||||||
getMacroDistributionChart: (days=28) => req(`/charts/macro-distribution?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}`),
|
getWeeklyMacroDistributionChart: (weeks=12) => req(`/charts/weekly-macro-distribution?weeks=${weeks}`),
|
||||||
|
getNutritionAdherenceScore: (days=28) => req(`/charts/nutrition-adherence-score?days=${days}`),
|
||||||
|
getEnergyAvailabilityWarning: (days=14) => req(`/charts/energy-availability-warning?days=${days}`),
|
||||||
|
|
||||||
// Recovery Charts (R1-R5)
|
// Recovery Charts (R1-R5)
|
||||||
getRecoveryScoreChart: (days=28) => req(`/charts/recovery-score?days=${days}`),
|
getRecoveryScoreChart: (days=28) => req(`/charts/recovery-score?days=${days}`),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user