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
|
||||
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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_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"},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', marginBottom: 12, lineHeight: 1.65 }}>
|
||||
<strong>Eigenes Berichtsprofil:</strong> Reihenfolge, Überschriften und Diagramme — unabhängig von der
|
||||
Startübersicht. Die PDF-Datei wird <strong>serverseitig</strong> aus denselben Datenquellen wie die
|
||||
Chart-API erzeugt (kein Screenshot). Das unterscheidet sich vom optionalen Widget „Übersicht als
|
||||
Bild-PDF“ auf der Startseite.
|
||||
<strong>Eigenes Berichtsprofil:</strong> Überschriften, <strong>Verlauf-Bundles</strong> (KPIs,
|
||||
Einschätzungen und Diagramme wie im Bereich Verlauf) und optional einzelne Legacy-Diagramme. Gleiche
|
||||
Schalter wie unter{' '}
|
||||
<Link to="/settings/dashboard-layout" style={{ color: 'var(--accent)' }}>
|
||||
Übersicht anpassen
|
||||
</Link>
|
||||
. PDF wird serverseitig aus dem Datenlayer erzeugt — kein Screenshot der Widgets.
|
||||
</p>
|
||||
{!canExport && (
|
||||
<div
|
||||
|
|
@ -755,6 +766,108 @@ export default function SettingsPage() {
|
|||
/>
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -772,7 +885,8 @@ export default function SettingsPage() {
|
|||
>
|
||||
<option value="">+ Block hinzufügen…</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>
|
||||
</select>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user