From 3ab5dae1307a44a710ba8c94701bbc28db8facfe Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 29 Apr 2026 11:46:34 +0200 Subject: [PATCH] feat: add viz_bundle support to report generation and enhance schema - Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports. - Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report. - Enhanced the report catalog API to include details for the new `viz_bundle` block type. - Added configuration editors for various visualization bundles in the frontend settings page. - Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles. - Bumped application version to reflect these enhancements. --- backend/report_chart_plotting.py | 91 +++++ backend/report_pdf_render.py | 94 +---- backend/report_profile_schema.py | 52 ++- backend/report_viz_bundle_pdf.py | 386 ++++++++++++++++++++ backend/routers/reports.py | 14 +- backend/tests/test_report_profile_schema.py | 25 ++ frontend/src/pages/SettingsPage.jsx | 124 ++++++- 7 files changed, 682 insertions(+), 104 deletions(-) create mode 100644 backend/report_chart_plotting.py create mode 100644 backend/report_viz_bundle_pdf.py diff --git a/backend/report_chart_plotting.py b/backend/report_chart_plotting.py new file mode 100644 index 0000000..2b709de --- /dev/null +++ b/backend/report_chart_plotting.py @@ -0,0 +1,91 @@ +"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt.""" +from __future__ import annotations + +import io +from typing import Any + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]: + s = (hex_or_rgba or "#333333").strip() + if s.startswith("#") and len(s) >= 7: + try: + r = int(s[1:3], 16) / 255.0 + g = int(s[3:5], 16) / 255.0 + b = int(s[5:7], 16) / 255.0 + return (r, g, b) + except ValueError: + pass + return (0.12, 0.62, 0.46) + + +def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes: + chart_type = payload.get("chart_type") or "line" + data = payload.get("data") or {} + labels = data.get("labels") or [] + datasets = data.get("datasets") or [] + + fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120) + ax.set_facecolor("#fafaf9") + fig.patch.set_facecolor("#ffffff") + + if chart_type == "pie" and datasets: + ds0 = datasets[0] + values = ds0.get("data") or [] + colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"] + if labels and values and len(labels) == len(values): + ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90) + ax.axis("equal") + else: + ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes) + + elif chart_type in ("line", "bar", "scatter") and datasets: + x = range(len(labels)) if labels else [] + for i, ds in enumerate(datasets): + y = ds.get("data") or [] + if not y: + continue + lab = ds.get("label") or f"Serie {i + 1}" + col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75")) + if chart_type == "bar": + yv = y[: len(labels)] if labels else y + bg = ds.get("backgroundColor") + if isinstance(bg, list): + cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]] + else: + cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv) + ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88) + else: + ax.plot( + list(x)[: len(y)], + y, + label=lab, + color=col, + linewidth=1.6, + marker="o", + markersize=2, + ) + if labels and chart_type != "bar": + step = max(1, len(labels) // 8) + ax.set_xticks(list(x)[::step]) + ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7) + elif labels and chart_type == "bar": + ax.set_xticks(list(x)) + ax.set_xticklabels(labels, rotation=30, fontsize=7) + ax.legend(loc="upper right", fontsize=7) + ax.grid(True, alpha=0.25) + ax.set_xmargin(0.02) + + else: + ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes) + + fig.tight_layout() + buf = io.BytesIO() + fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor()) + plt.close(fig) + buf.seek(0) + return buf.read() diff --git a/backend/report_pdf_render.py b/backend/report_pdf_render.py index 3dee6ca..0043e54 100644 --- a/backend/report_pdf_render.py +++ b/backend/report_pdf_render.py @@ -1,5 +1,5 @@ """ -PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart.js-ähnliche Payloads. +PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads. """ from __future__ import annotations @@ -8,10 +8,6 @@ import logging from typing import Any from xml.sax.saxutils import escape -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.units import mm @@ -20,100 +16,21 @@ from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer from db import get_cursor, get_db from report_chart_fetch import fetch_chart_payload +from report_chart_plotting import chart_payload_to_png from report_profile_schema import ( AiInsightBlock, ChartBlock, ReportProfilePayload, SectionBlock, + VizBundleBlock, ) +from report_viz_bundle_pdf import append_viz_bundle_to_story logger = logging.getLogger(__name__) _CONTENT_TRUNCATE = 12000 -def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]: - s = (hex_or_rgba or "#333333").strip() - if s.startswith("#") and len(s) >= 7: - try: - r = int(s[1:3], 16) / 255.0 - g = int(s[3:5], 16) / 255.0 - b = int(s[5:7], 16) / 255.0 - return (r, g, b) - except ValueError: - pass - return (0.12, 0.62, 0.46) - - -def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes: - """Erzeugt PNG aus Chart.js-kompatiblem Payload (line, bar, pie).""" - chart_type = payload.get("chart_type") or "line" - data = payload.get("data") or {} - labels = data.get("labels") or [] - datasets = data.get("datasets") or [] - - fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120) - ax.set_facecolor("#fafaf9") - fig.patch.set_facecolor("#ffffff") - - if chart_type == "pie" and datasets: - ds0 = datasets[0] - values = ds0.get("data") or [] - colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"] - if labels and values and len(labels) == len(values): - ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90) - ax.axis("equal") - else: - ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes) - - elif chart_type in ("line", "bar", "scatter") and datasets: - x = range(len(labels)) if labels else [] - for i, ds in enumerate(datasets): - y = ds.get("data") or [] - if not y: - continue - lab = ds.get("label") or f"Serie {i + 1}" - col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75")) - if chart_type == "bar": - yv = y[: len(labels)] if labels else y - bg = ds.get("backgroundColor") - if isinstance(bg, list): - cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]] - else: - cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv) - ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88) - else: - ax.plot( - list(x)[: len(y)], - y, - label=lab, - color=col, - linewidth=1.6, - marker="o", - markersize=2, - ) - if labels and chart_type != "bar": - step = max(1, len(labels) // 8) - ax.set_xticks(list(x)[::step]) - ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7) - elif labels and chart_type == "bar": - ax.set_xticks(list(x)) - ax.set_xticklabels(labels, rotation=30, fontsize=7) - ax.legend(loc="upper right", fontsize=7) - ax.grid(True, alpha=0.25) - ax.set_xmargin(0.02) - - else: - ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes) - - fig.tight_layout() - buf = io.BytesIO() - fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor()) - plt.close(fig) - buf.seek(0) - return buf.read() - - def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]: """Returns (heading, body_text).""" if not insight_id: @@ -172,6 +89,8 @@ def build_structured_report_pdf( story.append(Spacer(1, 4 * mm)) story.append(Paragraph(escape(block.title), styles["Heading2"])) story.append(Spacer(1, 2 * mm)) + elif isinstance(block, VizBundleBlock): + append_viz_bundle_to_story(story, styles, profile_id, block.bundle_id, block.config) elif isinstance(block, ChartBlock): try: chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days) @@ -188,7 +107,6 @@ def build_structured_report_pdf( try: png = chart_payload_to_png(chart) img_buf = io.BytesIO(png) - # Breite ~ volle Textbreite (~180mm auf A4 mit Standardrändern Platypus) iw = 170 * mm ih = 85 * mm story.append(RLImage(img_buf, width=iw, height=ih)) diff --git a/backend/report_profile_schema.py b/backend/report_profile_schema.py index 406d3ca..dc6d706 100644 --- a/backend/report_profile_schema.py +++ b/backend/report_profile_schema.py @@ -3,15 +3,18 @@ Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layou Block-Typen: - section: Überschrift +- viz_bundle: Layer-2b-Ver bundles (KPIs, Text, Charts) — gleiche Config wie Dashboard - chart: diagramm via report_chart_fetch (chart_id + window_days) - ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl """ from __future__ import annotations -from typing import Literal, Union +from typing import Any, Literal, Union from pydantic import BaseModel, Field, model_validator +from dashboard_widget_config import validate_widget_entry_config + ALLOWED_CHART_IDS: frozenset[str] = frozenset( { "weight_trend", @@ -22,7 +25,17 @@ ALLOWED_CHART_IDS: frozenset[str] = frozenset( } ) -_MAX_BLOCKS = 24 +_MAX_BLOCKS = 32 + +ALLOWED_VIZ_BUNDLE_IDS: frozenset[str] = frozenset( + { + "body_history_viz", + "nutrition_history_viz", + "fitness_history_viz", + "recovery_history_viz", + "history_overview_viz", + } +) class SectionBlock(BaseModel): @@ -48,10 +61,27 @@ class AiInsightBlock(BaseModel): insight_id: str | None = Field(default=None, max_length=48) +class VizBundleBlock(BaseModel): + """Gleiche Layer-2b-Bundles wie im Dashboard; config wie validate_widget_entry_config.""" + + type: Literal["viz_bundle"] = "viz_bundle" + bundle_id: str = Field(min_length=1, max_length=64) + config: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def _bundle_config(self) -> VizBundleBlock: + if self.bundle_id not in ALLOWED_VIZ_BUNDLE_IDS: + raise ValueError( + f"Unbekanntes bundle_id: {self.bundle_id!r} (erlaubt: {sorted(ALLOWED_VIZ_BUNDLE_IDS)})" + ) + self.config = validate_widget_entry_config(self.bundle_id, self.config) + return self + + class ReportProfilePayload(BaseModel): version: Literal[1] = 1 document_title: str = Field(default="", max_length=120) - blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock]] + blocks: list[Union[SectionBlock, ChartBlock, AiInsightBlock, VizBundleBlock]] @model_validator(mode="after") def _blocks_limit(self) -> ReportProfilePayload: @@ -74,12 +104,16 @@ def default_report_profile_dict() -> dict: p = ReportProfilePayload( document_title="", blocks=[ - SectionBlock(title="Körpergewicht"), - ChartBlock(chart_id="weight_trend", window_days=90), - SectionBlock(title="Energiebilanz"), - ChartBlock(chart_id="energy_balance", window_days=28), - SectionBlock(title="Trainingsvolumen"), - ChartBlock(chart_id="training_volume", window_days=84), + SectionBlock(title="Verlauf — Körper"), + VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}), + SectionBlock(title="Verlauf — Ernährung"), + VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}), + SectionBlock(title="Verlauf — Fitness"), + VizBundleBlock(bundle_id="fitness_history_viz", config={"chart_days": 90}), + SectionBlock(title="Verlauf — Erholung"), + VizBundleBlock(bundle_id="recovery_history_viz", config={"chart_days": 90}), + SectionBlock(title="Gesamtübersicht"), + VizBundleBlock(bundle_id="history_overview_viz", config={"chart_days": 90}), ], ) return p.to_stored_dict() diff --git a/backend/report_viz_bundle_pdf.py b/backend/report_viz_bundle_pdf.py new file mode 100644 index 0000000..b020ff4 --- /dev/null +++ b/backend/report_viz_bundle_pdf.py @@ -0,0 +1,386 @@ +""" +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", + } + ) + 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"{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"])) diff --git a/backend/routers/reports.py b/backend/routers/reports.py index 99a8cfd..05555e0 100644 --- a/backend/routers/reports.py +++ b/backend/routers/reports.py @@ -19,6 +19,7 @@ from feature_logger import log_feature_usage from report_chart_fetch import CHART_CATALOG_FOR_API from report_pdf_render import build_structured_report_pdf from report_profile_schema import ( + ALLOWED_VIZ_BUNDLE_IDS, ReportProfilePayload, default_report_profile_dict, parse_report_profile, @@ -31,12 +32,21 @@ logger = logging.getLogger(__name__) @router.get("/catalog") def get_reports_catalog(session: dict = Depends(require_auth)): """Metadaten für UI: verfügbare Diagramme und Blocktypen.""" + viz_titles = { + "body_history_viz": "Körper (Verlauf-Bundle)", + "nutrition_history_viz": "Ernährung (Verlauf-Bundle)", + "fitness_history_viz": "Fitness (Verlauf-Bundle)", + "recovery_history_viz": "Erholung (Verlauf-Bundle)", + "history_overview_viz": "Gesamtübersicht (Korrelationen)", + } return { - "catalog_version": 1, + "catalog_version": 2, "charts": CHART_CATALOG_FOR_API, + "viz_bundles": [{"id": bid, "title": viz_titles.get(bid, bid)} for bid in sorted(ALLOWED_VIZ_BUNDLE_IDS)], "block_types": [ {"id": "section", "title": "Überschrift"}, - {"id": "chart", "title": "Diagramm"}, + {"id": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"}, + {"id": "chart", "title": "Einzel-Diagramm (Legacy)"}, {"id": "ai_insight", "title": "KI-Auswertung"}, ], } diff --git a/backend/tests/test_report_profile_schema.py b/backend/tests/test_report_profile_schema.py index 4411c76..22599cb 100644 --- a/backend/tests/test_report_profile_schema.py +++ b/backend/tests/test_report_profile_schema.py @@ -29,3 +29,28 @@ def test_chart_block_unknown_id_raises(): } with pytest.raises(Exception): ReportProfilePayload.model_validate(raw) + + +def test_viz_bundle_roundtrip(): + raw = { + "version": 1, + "document_title": "", + "blocks": [ + {"type": "viz_bundle", "bundle_id": "body_history_viz", "config": {"chart_days": 14}}, + ], + } + p = ReportProfilePayload.model_validate(raw) + assert p.blocks[0].type == "viz_bundle" + assert p.blocks[0].config.get("chart_days") == 14 + + +def test_viz_bundle_unknown_raises(): + import pytest + + raw = { + "version": 1, + "document_title": "", + "blocks": [{"type": "viz_bundle", "bundle_id": "not_a_bundle", "config": {}}], + } + with pytest.raises(Exception): + ReportProfilePayload.model_validate(raw) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index fc3269d..979d2dc 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -5,6 +5,11 @@ import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' +import BodyHistoryVizConfigEditor from '../widgetSystem/BodyHistoryVizConfigEditor' +import NutritionHistoryVizConfigEditor from '../widgetSystem/NutritionHistoryVizConfigEditor' +import FitnessHistoryVizConfigEditor from '../widgetSystem/FitnessHistoryVizConfigEditor' +import RecoveryHistoryVizConfigEditor from '../widgetSystem/RecoveryHistoryVizConfigEditor' +import HistoryOverviewVizConfigEditor from '../widgetSystem/HistoryOverviewVizConfigEditor' import FeatureUsageOverview from '../components/FeatureUsageOverview' import UsageBadge from '../components/UsageBadge' @@ -57,7 +62,10 @@ export default function SettingsPage() { const reportNewBlock = (kind) => { const charts = reportCatalog?.charts || [] const first = charts[0] + const bundles = reportCatalog?.viz_bundles || [] + const firstBundleId = bundles[0]?.id || 'body_history_viz' if (kind === 'section') return { type: 'section', title: 'Neue Überschrift' } + if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} } if (kind === 'chart') return { type: 'chart', @@ -586,10 +594,13 @@ export default function SettingsPage() { PDF-Bericht (strukturiert)

- Eigenes Berichtsprofil: Reihenfolge, Überschriften und Diagramme — unabhängig von der - Startübersicht. Die PDF-Datei wird serverseitig aus denselben Datenquellen wie die - Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget „Übersicht als - Bild-PDF“ auf der Startseite. + Eigenes Berichtsprofil: Überschriften, Verlauf-Bundles (KPIs, + Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche + Schalter wie unter{' '} + + Übersicht anpassen + + . PDF wird serverseitig aus dem Datenlayer erzeugt — kein Screenshot der Widgets.

{!canExport && (
)} + {b.type === 'viz_bundle' && ( +
+
+ + +
+ {b.bundle_id === 'body_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'nutrition_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'fitness_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'recovery_history_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} + {b.bundle_id === 'history_overview_viz' && ( + + setReportDraft((d) => { + const blocks = d.blocks.map((x, j) => { + if (j !== idx) return x + if (Object.keys(next).length === 0) return { ...x, config: {} } + return { ...x, config: { ...(x.config || {}), ...next } } + }) + return { ...d, blocks } + }) + } + /> + )} +
+ )} ))} @@ -772,7 +885,8 @@ export default function SettingsPage() { > - + +