""" 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)}, ] def build_nutrition_correlation_heuristic_items( merged_rows: List[Dict[str, Any]], tdee_kcal: float, protein_target_low_g: float, ) -> List[Dict[str, Any]]: """ Heuristische Kurz-Aussagen (vormals Reiter «Korrelation») — gleiche Logik wie History.jsx, TDEE aber aus Data-Layer (nutrition_metrics / estimate_tdee), nicht ×1,4 im Frontend. """ filtered = [ r for r in merged_rows if r.get("kcal") is not None and r.get("weight") is not None ] if len(filtered) < 5: return [] td = float(tdee_kcal) latest_w = float(filtered[-1].get("weight") or 0) or 80.0 pt_low = round(float(protein_target_low_g or 0)) or max(1, round(latest_w * 1.6)) items: List[Dict[str, Any]] = [] if len(filtered) >= 14: high_k = [d for d in filtered if float(d.get("kcal") or 0) > td + 200] low_k = [d for d in filtered if float(d.get("kcal") or 0) < td - 200] if len(high_k) >= 3 and len(low_k) >= 3: avg_wh = sum(float(d["weight"]) for d in high_k) / len(high_k) avg_wl = sum(float(d["weight"]) for d in low_k) / len(low_k) avg_wh_r = round(avg_wh * 10) / 10 avg_wl_r = round(avg_wl * 10) / 10 items.append( { "icon": "📊", "status": "good" if avg_wl < avg_wh else "warn", "title": ( f"Kalorienreduktion wirkt: Ø {avg_wl_r} kg bei Defizit vs. {avg_wh_r} kg bei Überschuss" if avg_wl < avg_wh else "Kein klarer Kalorieneffekt auf Gewicht erkennbar" ), "detail": ( f"Tage mit Überschuss (>{int(td + 200)} kcal): Ø {avg_wh_r} kg · " f"Tage mit Defizit (<{int(td - 200)} kcal): Ø {avg_wl_r} kg" ), } ) prot_vs_lean = [ d for d in filtered if d.get("protein_g") is not None and d.get("lean_mass") is not None ] if len(prot_vs_lean) >= 3: high_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) >= pt_low] low_p = [d for d in prot_vs_lean if float(d.get("protein_g") or 0) < pt_low] if len(high_p) >= 2 and len(low_p) >= 2: avg_lh = sum(float(d["lean_mass"]) for d in high_p) / len(high_p) avg_ll = sum(float(d["lean_mass"]) for d in low_p) / len(low_p) avg_lh_r = round(avg_lh * 10) / 10 avg_ll_r = round(avg_ll * 10) / 10 items.append( { "icon": "🥩", "status": "good" if avg_lh >= avg_ll else "warn", "title": ( f"Hohe Proteinzufuhr (≥{pt_low} g): Ø {avg_lh_r} kg Mager · Niedrig: Ø {avg_ll_r} kg" ), "detail": ( f"{len(high_p)} Messpunkte mit hoher vs. {len(low_p)} mit niedriger Proteinzufuhr verglichen." ), } ) balances = [float(d["kcal"]) - td for d in filtered if d.get("kcal") is not None] avg_balance = int(round(sum(balances) / len(balances))) if balances else 0 ab_s = f"{avg_balance:+d}" if avg_balance > 0 else str(avg_balance) if avg_balance < -100: ic, st = "✅", "good" elif avg_balance > 200: ic, st = "⬆️", "warn" if avg_balance > 300 else "good" else: ic, st = "➡️", "good" if avg_balance < -500: bal_detail = "Starkes Defizit – Muskelerhalt durch ausreichend Protein sicherstellen." elif avg_balance < -100: bal_detail = "Moderates Defizit – ideal für Fettabbau bei Muskelerhalt." elif avg_balance > 300: bal_detail = "Kalorienüberschuss – günstig für Muskelaufbau, Fettzunahme möglich." else: bal_detail = "Nahezu ausgeglichen – Gewicht sollte stabil bleiben." items.append( { "icon": ic, "status": st, "title": f"Ø Kalorienbilanz: {ab_s} kcal/Tag", "detail": f"Geschätzter TDEE: {int(round(td))} kcal (Data-Layer, konsistent mit Verlauf). {bal_detail}", } ) return items