387 lines
18 KiB
Python
387 lines
18 KiB
Python
|
|
"""
|
|||
|
|
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"<i>{msg}</i>", styles["Normal"]))
|
|||
|
|
story.append(Spacer(1, 2 * mm))
|
|||
|
|
return
|
|||
|
|
if caption:
|
|||
|
|
story.append(Paragraph(f"<b>{escape(caption)}</b>", 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("<b>Einschätzungen</b>", 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"<b>{cat}</b>"]
|
|||
|
|
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("<b>KPI-Kacheln</b>", 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"• <b>{cat}</b>: {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"<b>{escape(label)}</b>", 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",
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
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 _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_distribution_pair", False) or cfg.get("show_macro_daily_bars", False):
|
|||
|
|
wm = bundle.get("weekly_macro_chart")
|
|||
|
|
if isinstance(wm, dict) and wm.get("chart_type"):
|
|||
|
|
_add_chart_to_story(story, styles, wm, "Makros (wöchentlich)")
|
|||
|
|
kw = bundle.get("kcal_vs_weight") or {}
|
|||
|
|
if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
|
|||
|
|
pts = kw["points"]
|
|||
|
|
if pts:
|
|||
|
|
pl = _line_payload_from_points(pts, "date", "kcal", "kcal")
|
|||
|
|
if pl:
|
|||
|
|
_add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
|
|||
|
|
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"<b>{title}</b>: {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"]))
|