- Bumped application version to 0.9t and updated changelog with new features. - Integrated new chart payloads for energy balance, protein adequacy, and nutrition adherence to optimize data retrieval and reduce HTTP requests. - Updated NutritionCharts component to utilize prefetched chart payloads, improving loading efficiency and user experience. - Refactored History page to pass chart payloads, enhancing the visualization of nutrition trends without additional requests.
405 lines
12 KiB
Python
405 lines
12 KiB
Python
"""
|
|
Chart.js-kompatible Payloads für Ernährungs-Charts (E1, E2, E4).
|
|
|
|
Gleiche Logik wie ``routers/charts.py`` — hier zentral, damit ``nutrition_viz``
|
|
und die API dieselbe Berechnung nutzen (Phase C, Issue 53).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict
|
|
|
|
from db import get_db, get_cursor
|
|
from data_layer.nutrition_metrics import (
|
|
get_energy_balance_data,
|
|
get_protein_adequacy_data,
|
|
get_protein_targets_data,
|
|
)
|
|
from data_layer.utils import calculate_confidence, safe_float, serialize_dates
|
|
|
|
|
|
def build_energy_balance_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
"""E1 Energiebilanz — identisch zu GET /api/charts/energy-balance."""
|
|
balance_meta = get_energy_balance_data(profile_id, days)
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
|
|
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
|
|
ORDER BY date""",
|
|
(profile_id, cutoff),
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
if not rows or len(rows) < 3:
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {"labels": [], "datasets": []},
|
|
"metadata": {
|
|
"confidence": "insufficient",
|
|
"data_points": len(rows) if rows else 0,
|
|
"message": "Nicht genug Ernährungsdaten (min. 3 Tage)",
|
|
},
|
|
}
|
|
|
|
estimated_tdee = balance_meta.get("estimated_tdee") or 0
|
|
if estimated_tdee <= 0:
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {"labels": [], "datasets": []},
|
|
"metadata": {
|
|
"confidence": "insufficient",
|
|
"data_points": len(rows),
|
|
"message": "Kein Gewicht für TDEE-Schätzung (weight_log erforderlich)",
|
|
},
|
|
}
|
|
|
|
labels = []
|
|
daily_values = []
|
|
avg_7d = []
|
|
avg_14d = []
|
|
|
|
for i, row in enumerate(rows):
|
|
labels.append(row["date"].isoformat())
|
|
daily_values.append(safe_float(row["kcal"]))
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
avg_intake = float(
|
|
balance_meta.get("avg_intake")
|
|
or (sum(daily_values) / len(daily_values) if daily_values else 0)
|
|
)
|
|
energy_balance = float(
|
|
balance_meta.get("energy_balance") or (avg_intake - estimated_tdee)
|
|
)
|
|
balance_status = balance_meta.get("status") or (
|
|
"deficit"
|
|
if energy_balance < -200
|
|
else "surplus"
|
|
if energy_balance > 200
|
|
else "maintenance"
|
|
)
|
|
|
|
datasets = [
|
|
{
|
|
"label": "Kalorien (täglich)",
|
|
"data": daily_values,
|
|
"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": "Ø 14 Tage",
|
|
"data": avg_14d,
|
|
"borderColor": "#085041",
|
|
"borderWidth": 2,
|
|
"tension": 0.3,
|
|
"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,
|
|
},
|
|
]
|
|
|
|
confidence = balance_meta.get("confidence") or "low"
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {"labels": labels, "datasets": datasets},
|
|
"metadata": serialize_dates(
|
|
{
|
|
"confidence": confidence,
|
|
"data_points": len(rows),
|
|
"avg_kcal": round(avg_intake, 1),
|
|
"estimated_tdee": estimated_tdee,
|
|
"energy_balance": round(energy_balance, 1),
|
|
"balance_status": balance_status,
|
|
"first_date": rows[0]["date"],
|
|
"last_date": rows[-1]["date"],
|
|
}
|
|
),
|
|
}
|
|
|
|
|
|
def build_protein_adequacy_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
"""E2 Protein Adequacy — identisch zu GET /api/charts/protein-adequacy."""
|
|
targets = get_protein_targets_data(profile_id)
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
|
|
cur.execute(
|
|
"""SELECT date, SUM(protein_g)::float AS protein_g
|
|
FROM nutrition_log
|
|
WHERE profile_id=%s AND date >= %s AND protein_g IS NOT NULL
|
|
GROUP BY date
|
|
ORDER BY date""",
|
|
(profile_id, cutoff),
|
|
)
|
|
rows = cur.fetchall()
|
|
|
|
if not rows or len(rows) < 3:
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {"labels": [], "datasets": []},
|
|
"metadata": {
|
|
"confidence": "insufficient",
|
|
"data_points": len(rows) if rows else 0,
|
|
"message": "Nicht genug Protein-Daten (min. 3 Tage)",
|
|
},
|
|
}
|
|
|
|
labels = []
|
|
daily_values = []
|
|
avg_7d = []
|
|
avg_28d = []
|
|
|
|
for i, row in enumerate(rows):
|
|
labels.append(row["date"].isoformat())
|
|
daily_values.append(safe_float(row["protein_g"]))
|
|
|
|
start_7d = max(0, i - 6)
|
|
window_7d = [safe_float(rows[j]["protein_g"]) for j in range(start_7d, i + 1)]
|
|
avg_7d.append(round(sum(window_7d) / len(window_7d), 1) if window_7d else None)
|
|
|
|
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)
|
|
|
|
target_low = targets["protein_target_low"]
|
|
target_high = targets["protein_target_high"]
|
|
|
|
datasets = [
|
|
{
|
|
"label": "Protein (täglich)",
|
|
"data": daily_values,
|
|
"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],
|
|
"fill": False,
|
|
"pointRadius": 0,
|
|
},
|
|
]
|
|
|
|
datasets.append(
|
|
{
|
|
"label": "Ziel Max",
|
|
"data": [target_high] * len(labels),
|
|
"borderColor": "#888",
|
|
"borderWidth": 1,
|
|
"borderDash": [5, 5],
|
|
"fill": False,
|
|
"pointRadius": 0,
|
|
}
|
|
)
|
|
|
|
confidence = calculate_confidence(len(rows), days, "general")
|
|
|
|
days_in_target = sum(1 for v in daily_values if target_low <= v <= target_high)
|
|
|
|
return {
|
|
"chart_type": "line",
|
|
"data": {"labels": labels, "datasets": datasets},
|
|
"metadata": serialize_dates(
|
|
{
|
|
"confidence": confidence,
|
|
"data_points": len(rows),
|
|
"target_low": round(target_low, 1),
|
|
"target_high": round(target_high, 1),
|
|
"days_in_target": days_in_target,
|
|
"target_compliance_pct": round(
|
|
days_in_target / len(daily_values) * 100, 1
|
|
)
|
|
if daily_values
|
|
else 0,
|
|
"first_date": rows[0]["date"],
|
|
"last_date": rows[-1]["date"],
|
|
}
|
|
),
|
|
}
|
|
|
|
|
|
def build_nutrition_adherence_score_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
|
"""E4 Adhärenz — identisch zu GET /api/charts/nutrition-adherence-score."""
|
|
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")
|
|
|
|
cur.execute(
|
|
"""WITH daily AS (
|
|
SELECT date,
|
|
COALESCE(SUM(kcal), 0)::float AS dk,
|
|
COALESCE(SUM(protein_g), 0)::float AS dp,
|
|
COALESCE(SUM(carbs_g), 0)::float AS dc,
|
|
COALESCE(SUM(fat_g), 0)::float AS df FROM nutrition_log
|
|
WHERE profile_id=%s AND date >= %s AND kcal IS NOT NULL
|
|
GROUP BY date
|
|
)
|
|
SELECT COUNT(*)::int AS cnt,
|
|
AVG(dk) AS avg_kcal,
|
|
STDDEV(dk) AS std_kcal,
|
|
AVG(dp) AS avg_protein,
|
|
AVG(dc) AS avg_carbs,
|
|
AVG(df) AS avg_fat
|
|
FROM daily""",
|
|
(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)",
|
|
},
|
|
}
|
|
|
|
protein_data = get_protein_adequacy_data(profile_id, days)
|
|
|
|
calorie_adherence = 70.0
|
|
protein_adequacy_pct = protein_data.get("adequacy_score", 0)
|
|
protein_adherence = min(100, protein_adequacy_pct)
|
|
|
|
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)
|
|
|
|
food_quality = 60.0
|
|
|
|
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:
|
|
weights = {
|
|
"calorie": 0.25,
|
|
"protein": 0.25,
|
|
"consistency": 0.25,
|
|
"quality": 0.25,
|
|
}
|
|
|
|
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),
|
|
}
|
|
|
|
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,
|
|
},
|
|
}
|