""" Layer-2b Verlauf-Bundles → PDF-Abschnitte (KPIs + eingebettete Chart-Payloads). Gleiche Datenquellen und Config-Validierung wie Dashboard-Widgets (dashboard_widget_config). """ from __future__ import annotations import io import logging from typing import Any from reportlab.lib.units import mm from reportlab.platypus import Image as RLImage from reportlab.platypus import Paragraph, Spacer from xml.sax.saxutils import escape from dashboard_widget_config import validate_widget_entry_config from data_layer.body_viz import get_body_history_viz_bundle from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle from data_layer.history_overview_viz import get_history_overview_viz_bundle from data_layer.nutrition_viz import get_nutrition_history_viz_bundle from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle from data_layer.utils import safe_float from report_chart_plotting import chart_payload_to_png logger = logging.getLogger(__name__) BUNDLE_HEADINGS: dict[str, str] = { "body_history_viz": "Körper — Kennwerte & Verlauf", "nutrition_history_viz": "Ernährung — Kennwerte & Charts", "fitness_history_viz": "Fitness / Training", "recovery_history_viz": "Erholung & Vitalwerte", "history_overview_viz": "Gesamtübersicht & Korrelationen", } def _add_chart_to_story(story: list, styles: dict, payload: dict[str, Any], caption: str | None = None) -> None: meta = payload.get("metadata") or {} if meta.get("confidence") == "insufficient": msg = escape(meta.get("message") or "Keine Daten") story.append(Paragraph(f"{msg}", styles["Normal"])) story.append(Spacer(1, 2 * mm)) return if caption: story.append(Paragraph(f"{escape(caption)}", styles["Normal"])) try: png = chart_payload_to_png(payload) story.append(RLImage(io.BytesIO(png), width=170 * mm, height=85 * mm)) except Exception as e: logger.warning("bundle chart png: %s", e) story.append(Paragraph("Diagramm konnte nicht gerendert werden.", styles["Normal"])) story.append(Spacer(1, 4 * mm)) def _append_interpretation_tiles(story: list, styles: dict, tiles: list[dict[str, Any]]) -> None: if not tiles: return story.append(Paragraph("Einschätzungen", styles["Heading4"])) for t in tiles: cat = escape(str(t.get("category") or t.get("title") or "—")) title = t.get("title") detail = t.get("detail") val = t.get("value") parts = [f"{cat}"] if title and str(title) != str(cat): parts.append(escape(str(title))) if val is not None and val != "": parts.append(f"({escape(str(val))})") story.append(Paragraph(" — ".join(parts), styles["Normal"])) if detail: story.append(Paragraph(escape(str(detail)[:500]), styles["BodyText"])) story.append(Spacer(1, 3 * mm)) def _append_kpi_tiles_fitness_nutreco(story: list, styles: dict, tiles: list[dict[str, Any]], compact: bool) -> None: if not tiles: return use = tiles[:4] if compact else tiles story.append(Paragraph("KPI-Kacheln", styles["Heading4"])) for t in use: cat = escape(str(t.get("category") or t.get("title") or "—")) val = escape(str(t.get("value") or "—")) sub = t.get("sublabel") or t.get("body") line = f"• {cat}: {val}" if sub: line += f" — {escape(str(sub)[:180])}" story.append(Paragraph(line, styles["Normal"])) story.append(Spacer(1, 3 * mm)) def _append_insights_lines(story: list, styles: dict, insights: list[dict[str, Any]], label: str) -> None: if not insights: return story.append(Paragraph(f"{escape(label)}", styles["Heading4"])) for item in insights: title = item.get("title") or item.get("heading") body = item.get("body") or item.get("text") if title: story.append(Paragraph(escape(str(title)), styles["Normal"])) if body: story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"])) story.append(Spacer(1, 2 * mm)) def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None: series = bundle_weight.get("series") or [] if len(series) < 2: return None labels = [str(p.get("date") or "") for p in series] datasets: list[dict[str, Any]] = [ { "label": "Gewicht (kg)", "data": [safe_float(p.get("weight")) for p in series], "borderColor": "#1D9E75", } ] if any(p.get("avg7") is not None for p in series): datasets.append( { "label": "Ø 7T", "data": [safe_float(p.get("avg7")) for p in series], "borderColor": "#378ADD", } ) if any(p.get("avg14") is not None for p in series): datasets.append( { "label": "Ø 14T", "data": [safe_float(p.get("avg14")) for p in series], "borderColor": "#085041", "borderDash": [6, 3], } ) return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}} def _line_payload_from_points( points: list[dict[str, Any]], x_key: str, y_key: str, label: str, ) -> dict[str, Any] | None: if len(points) < 2: return None labels = [str(p.get(x_key) or "") for p in points] ys = [safe_float(p.get(y_key)) for p in points] return { "chart_type": "line", "data": { "labels": labels, "datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}], }, "metadata": {"confidence": "high"}, } def _donut_avg_to_pie_payload(donut: list[dict[str, Any]] | None) -> dict[str, Any] | None: if not donut: return None labels = [str(x.get("name") or "") for x in donut] values = [safe_float(x.get("value")) for x in donut] colors = [str(x.get("color") or "#666666") for x in donut] if not labels or not values or len(labels) != len(values): return None return { "chart_type": "pie", "data": { "labels": labels, "datasets": [{"data": values, "backgroundColor": colors}], }, "metadata": {"confidence": "high"}, } def _daily_macros_stacked_bar_payload(daily_macros: list[dict[str, Any]]) -> dict[str, Any] | None: if len(daily_macros) < 2: return None labels = [str(d.get("date") or "") for d in daily_macros] return { "chart_type": "bar", "data": { "labels": labels, "datasets": [ { "label": "Protein (g)", "data": [safe_float(d.get("Protein")) for d in daily_macros], "backgroundColor": "#4a8f72", "stack": "daily", }, { "label": "Fett (g)", "data": [safe_float(d.get("Fett")) for d in daily_macros], "backgroundColor": "#6e8eb8", "stack": "daily", }, { "label": "KH (g)", "data": [safe_float(d.get("KH")) for d in daily_macros], "backgroundColor": "#c17d45", "stack": "daily", }, ], }, "metadata": {"confidence": "high"}, } def _kcal_vs_weight_dual_payload(kw: dict[str, Any]) -> dict[str, Any] | None: pts = kw.get("points") or [] if len(pts) < 2: return None labels = [str(p.get("date") or "") for p in pts] tdee = kw.get("tdee_reference_kcal") datasets: list[dict[str, Any]] = [ { "label": "Ø Kalorien (7T)", "data": [safe_float(p.get("kcal_avg")) for p in pts], "borderColor": "#EA580C", "yAxisID": "kcal", }, ] if tdee is not None and float(safe_float(tdee) or 0) > 0: td = float(tdee) datasets.append( { "label": "TDEE (geschätzt)", "data": [td] * len(labels), "borderColor": "#888888", "yAxisID": "kcal", "borderDash": [5, 5], } ) datasets.append( { "label": "Gewicht (kg)", "data": [safe_float(p.get("weight")) for p in pts], "borderColor": "#2563EB", "yAxisID": "weight", } ) return { "chart_type": "dual_axis_line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}, } def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: days = int(cfg.get("chart_days") or 30) bundle = get_body_history_viz_bundle(profile_id, days) story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"])) if bundle.get("confidence") == "insufficient": story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"])) story.append(Spacer(1, 4 * mm)) return summ = bundle.get("summary") or {} if summ: w = summ.get("weight_kg") bf = summ.get("body_fat_pct") parts = [] if w is not None: parts.append(f"Gewicht: {w} kg") if bf is not None: parts.append(f"KF%: {bf}") if parts: story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"])) story.append(Spacer(1, 2 * mm)) if cfg.get("show_kpis", True): _append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or []) w = bundle.get("weight") or {} if cfg.get("show_weight_chart", True): pl = _weight_series_payload(w) if pl: _add_chart_to_story(story, styles, pl, "Gewicht") cal = bundle.get("caliper") or {} if cfg.get("show_body_fat_chart", False): ser = cal.get("series") or [] pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None] pl = _line_payload_from_points(pts, "date", "y", "KF %") if pl: _add_chart_to_story(story, styles, pl, "Körperfett (Caliper)") circ = bundle.get("circumference") or {} if cfg.get("show_proportion_chart", False): prop = circ.get("proportion_series") or [] pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None] pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)") if pl: _add_chart_to_story(story, styles, pl, "Proportion (Brust–Taille)") if cfg.get("show_circumference_index_chart", False): idx = circ.get("index_series") or [] if len(idx) >= 2: labels = [str(p.get("date") or "") for p in idx] ds: list[dict[str, Any]] = [] for key, lab, col in ( ("waist_idx", "Taille-Index", "#D85A30"), ("chest_idx", "Brust-Index", "#1D9E75"), ("belly_idx", "Bauch-Index", "#378ADD"), ): ys = [safe_float(p.get(key)) for p in idx] if any(v is not None for v in ys): ds.append({"label": lab, "data": ys, "borderColor": col}) if ds: pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}} _add_chart_to_story(story, styles, pl, "Umfang-Indizes") story.append(Spacer(1, 2 * mm)) def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: days = int(cfg.get("chart_days") or 30) bundle = get_nutrition_history_viz_bundle(profile_id, days) story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"])) if not bundle.get("has_nutrition_entries"): story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"])) story.append(Spacer(1, 4 * mm)) return compact = cfg.get("kpi_detail") == "compact" if cfg.get("show_kpis", True): _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) if cfg.get("show_heuristics", False): h = bundle.get("nutrition_correlation_heuristics") or [] for item in h: t = item.get("text") or item.get("title") if t: story.append(Paragraph(f"• {escape(str(t))}", styles["Normal"])) story.append(Spacer(1, 2 * mm)) charts = bundle.get("chart_payloads") or {} if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False): pl = charts.get("energy_balance") if pl: _add_chart_to_story(story, styles, pl, "Energiebilanz") if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False): pl = charts.get("protein_adequacy") if pl: _add_chart_to_story(story, styles, pl, "Protein-Adäquanz") pl2 = charts.get("nutrition_adherence") if pl2: _add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence") if cfg.get("show_macro_daily_bars", False): dm = bundle.get("daily_macros") or [] pl_dm = _daily_macros_stacked_bar_payload(dm) if pl_dm: _add_chart_to_story(story, styles, pl_dm, "Makroverteilung täglich (g) · Fokus Protein") if cfg.get("show_macro_distribution_pair", False): pie_pl = _donut_avg_to_pie_payload(bundle.get("donut_avg_pct")) if pie_pl: _add_chart_to_story(story, styles, pie_pl, "Ø Makro-Quote (% der Makro-kcal)") wm = bundle.get("weekly_macro_chart") if isinstance(wm, dict) and wm.get("chart_type"): meta_wm = wm.get("metadata") or {} if meta_wm.get("confidence") != "insufficient": _add_chart_to_story(story, styles, wm, "Wöch. Makro-Verteilung (Anteil %)") kw = bundle.get("kcal_vs_weight") or {} if cfg.get("show_kcal_vs_weight", False) and kw.get("points"): pl_kw = _kcal_vs_weight_dual_payload(kw) if pl_kw: _add_chart_to_story(story, styles, pl_kw, "Kalorien (Ø 7 Tage) vs. Gewicht") story.append(Spacer(1, 2 * mm)) def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: days = int(cfg.get("chart_days") or 30) bundle = get_fitness_dashboard_viz_bundle(profile_id, days) story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"])) if not bundle.get("has_activity_entries"): story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"])) story.append(Spacer(1, 4 * mm)) return compact = cfg.get("kpi_detail") == "compact" if cfg.get("show_kpis", True): _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) if cfg.get("show_progress_insights", False): _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen") charts = bundle.get("charts") or {} if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"): _add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen") if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"): _add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten") if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"): _add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions") if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"): _add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR") story.append(Spacer(1, 2 * mm)) def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: days = int(cfg.get("chart_days") or 30) bundle = get_recovery_dashboard_viz_bundle(profile_id, days) story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"])) if not bundle.get("has_recovery_data"): story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"])) story.append(Spacer(1, 4 * mm)) return compact = cfg.get("kpi_detail") == "compact" if cfg.get("show_kpis", True): _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact) if cfg.get("show_progress_insights", False): _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen") charts = bundle.get("charts") or {} if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"): _add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score") if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"): _add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR") if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"): _add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität") if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"): _add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld") if cfg.get("show_vitals_extra_trends", False): if charts.get("vital_signs_matrix"): _add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix") if charts.get("vitals_history"): _add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends") story.append(Spacer(1, 2 * mm)) def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: days = int(cfg.get("chart_days") or 30) bundle = get_history_overview_viz_bundle(profile_id, days) story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"])) sect_keys = { "body": cfg.get("show_section_body", True), "nutrition": cfg.get("show_section_nutrition", True), "fitness": cfg.get("show_section_fitness", True), "recovery": cfg.get("show_section_recovery", True), } for sec in bundle.get("sections") or []: sid = sec.get("id") if not sect_keys.get(str(sid), True): continue title = escape(str(sec.get("title") or sid)) line = escape(str(sec.get("summary_line") or "")) story.append(Paragraph(f"{title}: {line}", styles["Normal"])) for it in sec.get("interpretation_short") or []: t = it.get("title") if isinstance(it, dict) else None if t: story.append(Paragraph(f"• {escape(str(t))}", styles["BodyText"])) for k in sec.get("kpi_short") or []: if isinstance(k, dict): cat = k.get("category") or k.get("title") val = k.get("value") if cat: story.append(Paragraph(f"• {escape(str(cat))}: {escape(str(val or ''))}", styles["BodyText"])) story.append(Spacer(1, 2 * mm)) if cfg.get("show_correlation_c1_c3", True) or cfg.get("show_drivers_c4", True): lag = bundle.get("lag_correlations") or {} we = lag.get("weight_energy") or {} if we.get("available") and (we.get("interpretation") or we.get("label")): lab = escape(str(we.get("label") or "C1")) interp = escape(str(we.get("interpretation") or "").strip()) if interp: story.append(Paragraph(f"{lab}: {interp}", styles["Normal"])) charts = bundle.get("chart_payloads") or {} if cfg.get("show_correlation_c1_c3", True): for key, cap in ( ("c1_weight_energy", "Korrelation Gewicht / Energie"), ("c2_protein_lbm", "Protein / Magermasse"), ("c3_load_vitals", "Last / Vitalwerte"), ): pl = charts.get(key) if pl: _add_chart_to_story(story, styles, pl, cap) if cfg.get("show_drivers_c4", True): pl = charts.get("c4_recovery_performance") if pl: _add_chart_to_story(story, styles, pl, "Top-Treiber") drv = (bundle.get("lag_correlations") or {}).get("recovery_performance") or {} for d in (drv.get("drivers") or [])[:12]: if isinstance(d, dict): lab = d.get("label") or d.get("factor") val = d.get("impact") or d.get("score") if lab: story.append(Paragraph(f"• {escape(str(lab))}: {escape(str(val or ''))}", styles["Normal"])) story.append(Spacer(1, 2 * mm)) def append_viz_bundle_to_story( story: list, styles: dict, profile_id: str, bundle_id: str, raw_config: dict[str, Any], ) -> None: cfg = validate_widget_entry_config(bundle_id, raw_config) if bundle_id == "body_history_viz": _append_body_bundle(story, styles, profile_id, cfg) elif bundle_id == "nutrition_history_viz": _append_nutrition_bundle(story, styles, profile_id, cfg) elif bundle_id == "fitness_history_viz": _append_fitness_bundle(story, styles, profile_id, cfg) elif bundle_id == "recovery_history_viz": _append_recovery_bundle(story, styles, profile_id, cfg) elif bundle_id == "history_overview_viz": _append_history_overview_bundle(story, styles, profile_id, cfg) else: story.append(Paragraph(escape(f"Unbekanntes Bundle: {bundle_id}"), styles["Normal"]))