- 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.
394 lines
14 KiB
Python
394 lines
14 KiB
Python
"""
|
||
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_body_merge import build_merged_daily_nutrition_body_rows
|
||
from data_layer.nutrition_interpretation import (
|
||
build_energy_availability_kpi_tile,
|
||
build_macro_donut_from_averages,
|
||
build_nutrition_correlation_heuristic_items,
|
||
build_nutrition_history_kpi_tiles,
|
||
)
|
||
from data_layer.nutrition_chart_payloads import (
|
||
build_energy_balance_chart_payload,
|
||
build_nutrition_adherence_score_payload,
|
||
build_protein_adequacy_chart_payload,
|
||
)
|
||
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 _filter_merged_rows_by_cutoff(
|
||
merged: List[Dict[str, Any]], cutoff: Optional[str]
|
||
) -> List[Dict[str, Any]]:
|
||
if not cutoff:
|
||
return list(merged)
|
||
return [r for r in merged if str(r.get("date"))[:10] >= cutoff]
|
||
|
||
|
||
def _calorie_balance_daily_series(
|
||
merged_filtered: List[Dict[str, Any]], tdee: float
|
||
) -> List[Dict[str, Any]]:
|
||
"""Tagesbilanz (Aufnahme − TDEE) + 7-Tage-Mittel der Bilanz — gleiche TDEE-Quelle wie kcal_vs_weight."""
|
||
rows: List[Dict[str, Any]] = []
|
||
for r in merged_filtered:
|
||
if r.get("kcal") is None:
|
||
continue
|
||
ds = _iso(r.get("date"))
|
||
if not ds:
|
||
continue
|
||
bal = round(float(r["kcal"]) - float(tdee))
|
||
rows.append({"date": ds, "balance_kcal": bal})
|
||
rolled = _rolling_avg([dict(x) for x in rows], "balance_kcal", 7)
|
||
out: List[Dict[str, Any]] = []
|
||
for x in rolled:
|
||
out.append(
|
||
{
|
||
"date": x["date"],
|
||
"balance_kcal": x.get("balance_kcal"),
|
||
"balance_kcal_avg": x.get("balance_kcal_avg"),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _protein_lean_mass_points(merged_filtered: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
out: List[Dict[str, Any]] = []
|
||
for r in merged_filtered:
|
||
if r.get("protein_g") is None or r.get("lean_mass") is None:
|
||
continue
|
||
ds = _iso(r.get("date"))
|
||
if not ds:
|
||
continue
|
||
out.append(
|
||
{
|
||
"date": ds,
|
||
"protein_g": round(safe_float(r.get("protein_g")) or 0, 1),
|
||
"lean_mass_kg": round(safe_float(r.get("lean_mass")) or 0, 2),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
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,
|
||
"calorie_balance_daily": [],
|
||
"protein_vs_lean_mass": {"points": [], "protein_target_low_g": None},
|
||
"nutrition_correlation_heuristics": [],
|
||
"chart_payloads": {},
|
||
"chart_payloads_days": 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))
|
||
|
||
merged_all = build_merged_daily_nutrition_body_rows(profile_id)
|
||
merged_win = _filter_merged_rows_by_cutoff(merged_all, cutoff)
|
||
tdee_eff = float(tdee) if tdee is not None else float(safe_float(energy_meta.get("estimated_tdee")) or 0)
|
||
calorie_balance_daily: List[Dict[str, Any]] = (
|
||
_calorie_balance_daily_series(merged_win, tdee_eff) if tdee_eff > 0 else []
|
||
)
|
||
pl_points = _protein_lean_mass_points(merged_win)
|
||
nutrition_correlation_heuristics = (
|
||
build_nutrition_correlation_heuristic_items(merged_win, tdee_eff, float(pt_low))
|
||
if tdee_eff > 0
|
||
else []
|
||
)
|
||
|
||
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)
|
||
|
||
# E1/E2/E4 Chart.js-Payloads — gleiche Funktionen wie /api/charts/* (kein zweiter HTTP-Roundtrip im Verlauf)
|
||
days_for_embedded_charts = max(7, min(int(chart_days_for_pipeline), 90))
|
||
chart_payloads = {
|
||
"energy_balance": build_energy_balance_chart_payload(
|
||
profile_id, days_for_embedded_charts
|
||
),
|
||
"protein_adequacy": build_protein_adequacy_chart_payload(
|
||
profile_id, days_for_embedded_charts
|
||
),
|
||
"nutrition_adherence": build_nutrition_adherence_score_payload(
|
||
profile_id, days_for_embedded_charts
|
||
),
|
||
}
|
||
|
||
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"),
|
||
},
|
||
"calorie_balance_daily": calorie_balance_daily,
|
||
"protein_vs_lean_mass": {
|
||
"points": pl_points,
|
||
"protein_target_low_g": pt_low if pt_low > 0 else None,
|
||
},
|
||
"nutrition_correlation_heuristics": nutrition_correlation_heuristics,
|
||
"chart_payloads": chart_payloads,
|
||
"chart_payloads_days": days_for_embedded_charts,
|
||
"meta": {
|
||
"layer_1": "nutrition_metrics",
|
||
"layer_2b": "nutrition_viz",
|
||
"issue": "53-phase-0c",
|
||
},
|
||
}
|