feat: add viz_bundle support to report generation and enhance schema
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s

- 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.
This commit is contained in:
Lars 2026-04-29 11:46:34 +02:00
parent 62729d0648
commit 3ab5dae130
7 changed files with 682 additions and 104 deletions

View File

@ -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()

View File

@ -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 from __future__ import annotations
@ -8,10 +8,6 @@ import logging
from typing import Any from typing import Any
from xml.sax.saxutils import escape 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.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import mm 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 db import get_cursor, get_db
from report_chart_fetch import fetch_chart_payload from report_chart_fetch import fetch_chart_payload
from report_chart_plotting import chart_payload_to_png
from report_profile_schema import ( from report_profile_schema import (
AiInsightBlock, AiInsightBlock,
ChartBlock, ChartBlock,
ReportProfilePayload, ReportProfilePayload,
SectionBlock, SectionBlock,
VizBundleBlock,
) )
from report_viz_bundle_pdf import append_viz_bundle_to_story
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CONTENT_TRUNCATE = 12000 _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]: def _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
"""Returns (heading, body_text).""" """Returns (heading, body_text)."""
if not insight_id: if not insight_id:
@ -172,6 +89,8 @@ def build_structured_report_pdf(
story.append(Spacer(1, 4 * mm)) story.append(Spacer(1, 4 * mm))
story.append(Paragraph(escape(block.title), styles["Heading2"])) story.append(Paragraph(escape(block.title), styles["Heading2"]))
story.append(Spacer(1, 2 * mm)) 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): elif isinstance(block, ChartBlock):
try: try:
chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days) chart = fetch_chart_payload(block.chart_id, profile_id, block.window_days)
@ -188,7 +107,6 @@ def build_structured_report_pdf(
try: try:
png = chart_payload_to_png(chart) png = chart_payload_to_png(chart)
img_buf = io.BytesIO(png) img_buf = io.BytesIO(png)
# Breite ~ volle Textbreite (~180mm auf A4 mit Standardrändern Platypus)
iw = 170 * mm iw = 170 * mm
ih = 85 * mm ih = 85 * mm
story.append(RLImage(img_buf, width=iw, height=ih)) story.append(RLImage(img_buf, width=iw, height=ih))

View File

@ -3,15 +3,18 @@ Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layou
Block-Typen: Block-Typen:
- section: Überschrift - 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) - chart: diagramm via report_chart_fetch (chart_id + window_days)
- ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl - ai_insight: optional insight_id (UUID), sonst Platzhalter für spätere Auswahl
""" """
from __future__ import annotations from __future__ import annotations
from typing import Literal, Union from typing import Any, Literal, Union
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from dashboard_widget_config import validate_widget_entry_config
ALLOWED_CHART_IDS: frozenset[str] = frozenset( ALLOWED_CHART_IDS: frozenset[str] = frozenset(
{ {
"weight_trend", "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): class SectionBlock(BaseModel):
@ -48,10 +61,27 @@ class AiInsightBlock(BaseModel):
insight_id: str | None = Field(default=None, max_length=48) 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): class ReportProfilePayload(BaseModel):
version: Literal[1] = 1 version: Literal[1] = 1
document_title: str = Field(default="", max_length=120) 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") @model_validator(mode="after")
def _blocks_limit(self) -> ReportProfilePayload: def _blocks_limit(self) -> ReportProfilePayload:
@ -74,12 +104,16 @@ def default_report_profile_dict() -> dict:
p = ReportProfilePayload( p = ReportProfilePayload(
document_title="", document_title="",
blocks=[ blocks=[
SectionBlock(title="Körpergewicht"), SectionBlock(title="Verlauf — Körper"),
ChartBlock(chart_id="weight_trend", window_days=90), VizBundleBlock(bundle_id="body_history_viz", config={"chart_days": 90}),
SectionBlock(title="Energiebilanz"), SectionBlock(title="Verlauf — Ernährung"),
ChartBlock(chart_id="energy_balance", window_days=28), VizBundleBlock(bundle_id="nutrition_history_viz", config={"chart_days": 90}),
SectionBlock(title="Trainingsvolumen"), SectionBlock(title="Verlauf — Fitness"),
ChartBlock(chart_id="training_volume", window_days=84), 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() return p.to_stored_dict()

View File

@ -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"<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 (BrustTaille)")
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"]))

View File

@ -19,6 +19,7 @@ from feature_logger import log_feature_usage
from report_chart_fetch import CHART_CATALOG_FOR_API from report_chart_fetch import CHART_CATALOG_FOR_API
from report_pdf_render import build_structured_report_pdf from report_pdf_render import build_structured_report_pdf
from report_profile_schema import ( from report_profile_schema import (
ALLOWED_VIZ_BUNDLE_IDS,
ReportProfilePayload, ReportProfilePayload,
default_report_profile_dict, default_report_profile_dict,
parse_report_profile, parse_report_profile,
@ -31,12 +32,21 @@ logger = logging.getLogger(__name__)
@router.get("/catalog") @router.get("/catalog")
def get_reports_catalog(session: dict = Depends(require_auth)): def get_reports_catalog(session: dict = Depends(require_auth)):
"""Metadaten für UI: verfügbare Diagramme und Blocktypen.""" """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 { return {
"catalog_version": 1, "catalog_version": 2,
"charts": CHART_CATALOG_FOR_API, "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": [ "block_types": [
{"id": "section", "title": "Überschrift"}, {"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"}, {"id": "ai_insight", "title": "KI-Auswertung"},
], ],
} }

View File

@ -29,3 +29,28 @@ def test_chart_block_unknown_id_raises():
} }
with pytest.raises(Exception): with pytest.raises(Exception):
ReportProfilePayload.model_validate(raw) 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)

View File

@ -5,6 +5,11 @@ import { useProfile } from '../context/ProfileContext'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { Avatar } from './ProfileSelect' import { Avatar } from './ProfileSelect'
import { api } from '../utils/api' 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 FeatureUsageOverview from '../components/FeatureUsageOverview'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
@ -57,7 +62,10 @@ export default function SettingsPage() {
const reportNewBlock = (kind) => { const reportNewBlock = (kind) => {
const charts = reportCatalog?.charts || [] const charts = reportCatalog?.charts || []
const first = charts[0] 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 === 'section') return { type: 'section', title: 'Neue Überschrift' }
if (kind === 'viz_bundle') return { type: 'viz_bundle', bundle_id: firstBundleId, config: {} }
if (kind === 'chart') if (kind === 'chart')
return { return {
type: 'chart', type: 'chart',
@ -586,10 +594,13 @@ export default function SettingsPage() {
PDF-Bericht (strukturiert) PDF-Bericht (strukturiert)
</div> </div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}> <p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
<strong>Eigenes Berichtsprofil:</strong> Reihenfolge, Überschriften und Diagramme unabhängig von der <strong>Eigenes Berichtsprofil:</strong> Überschriften, <strong>Verlauf-Bundles</strong> (KPIs,
Startübersicht. Die PDF-Datei wird <strong>serverseitig</strong> aus denselben Datenquellen wie die Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche
Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget Übersicht als Schalter wie unter{' '}
Bild-PDF auf der Startseite. <Link to="/settings/dashboard-layout" style={{ color: 'var(--accent)' }}>
Übersicht anpassen
</Link>
. PDF wird serverseitig aus dem Datenlayer erzeugt kein Screenshot der Widgets.
</p> </p>
{!canExport && ( {!canExport && (
<div <div
@ -755,6 +766,108 @@ export default function SettingsPage() {
/> />
</div> </div>
)} )}
{b.type === 'viz_bundle' && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 10 }}>
<div>
<label style={{ fontSize: 11, color: 'var(--text3)', display: 'block', marginBottom: 4 }}>
Verlauf-Bundle
</label>
<select
className="form-input"
value={b.bundle_id}
onChange={(e) =>
setReportDraft((d) => {
const blocks = d.blocks.map((x, j) =>
j === idx ? { ...x, bundle_id: e.target.value, config: {} } : x
)
return { ...d, blocks }
})
}
>
{(reportCatalog.viz_bundles || []).map((vb) => (
<option key={vb.id} value={vb.id}>
{vb.title}
</option>
))}
</select>
</div>
{b.bundle_id === 'body_history_viz' && (
<BodyHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<NutritionHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<FitnessHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<RecoveryHistoryVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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' && (
<HistoryOverviewVizConfigEditor
config={b.config || {}}
onChange={(next) =>
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 }
})
}
/>
)}
</div>
)}
</li> </li>
))} ))}
</ul> </ul>
@ -772,7 +885,8 @@ export default function SettingsPage() {
> >
<option value="">+ Block hinzufügen</option> <option value="">+ Block hinzufügen</option>
<option value="section">Überschrift</option> <option value="section">Überschrift</option>
<option value="chart">Diagramm</option> <option value="viz_bundle">Verlauf-Bundle (KPIs &amp; Diagramme)</option>
<option value="chart">Einzel-Diagramm (Legacy)</option>
<option value="ai_insight">KI-Auswertung</option> <option value="ai_insight">KI-Auswertung</option>
</select> </select>
</div> </div>