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.
This commit is contained in:
parent
62729d0648
commit
3ab5dae130
91
backend/report_chart_plotting.py
Normal file
91
backend/report_chart_plotting.py
Normal 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()
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
386
backend/report_viz_bundle_pdf.py
Normal file
386
backend/report_viz_bundle_pdf.py
Normal 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 (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"]))
|
||||||
|
|
@ -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"},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 & 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user