diff --git a/.claude/docs/README.md b/.claude/docs/README.md
index 8c409d9..4e26d9a 100644
--- a/.claude/docs/README.md
+++ b/.claude/docs/README.md
@@ -52,7 +52,7 @@ _Dieser Ordner `.claude/docs/` ist per `.gitignore`-Ausnahme **versioniert** (Sp
|--------|-------------|-------------------|
| Data Layer / Charts (Phase 0c) | `functional/DATA_ARCHITECTURE.md`, `technical/DATA_LAYER_EXTENSION_GUIDE.md` | `backend/data_layer/`, `backend/routers/charts.py` |
| Platzhalter / Registry | `technical/PLACEHOLDER_REGISTRY_FRAMEWORK.md`, `technical/PLACEHOLDER_DEVELOPMENT_GUIDE.md` | `backend/placeholder_registrations/`, `backend/placeholder_resolver.py` |
-| Dashboard-Lab-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
+| Dashboard-Widgets | `technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` | Widget-Katalog + Registrierung (siehe Guide) |
| Training Profiler / Resolver | `technical/TRAINING_PROFILE_RESOLVER_LAYER1.md`, `functional/TRAINING_TYPE_PROFILES.md` | Resolver-Module wie im Guide genannt |
| Universal CSV Import | `technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` | `backend/csv_parser/`, `routers/csv_import.py`, `routers/admin_csv_templates.py` |
| Aktivität Produktionsreife | `technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md` (+ EAV-Guide) | `backend/data_layer/activity_session_metrics.py`, `activity_metrics.py`, CSV-Orchestrierung |
diff --git a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md
index 7fafcac..dffae50 100644
--- a/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md
+++ b/.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md
@@ -1,7 +1,7 @@
-# Dashboard-Lab-Widgets – Anleitung für Coding-Agenten
+# Dashboard-Widgets – Anleitung für Coding-Agenten
-Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Lab-Editor für `config`).
-Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON).
+Ziel: Ein neues Dashboard-Widget **end-to-end** korrekt einbinden (Backend-Katalog, Validierung, API-Layout, Frontend-Registrierung, optional Editor für `config` in **Übersicht anpassen**).
+Kontext: Geschützte Endpoints `GET/PUT /api/app/...` (siehe `backend/routers/app_dashboard.py`). Layout liegt pro Profil in `profiles.dashboard_layout` (JSON). Nutzer-Oberfläche: `frontend/src/pages/DashboardConfigurePage.jsx` (Route z. B. `/settings/dashboard-layout`).
---
@@ -23,7 +23,7 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
| Anforderung | Beschreibung |
|-------------|--------------|
| **A1 – Zentrale Auflösung** | Backend ermittelt pro Profil (effektiver Tier + Restrictions), welche Widget-IDs **erlaubt** sind – idealerweise in **einer** Stelle (Erweiterung des Katalog-Endpoints oder dedizierter Entitlements-Teil der Response). Intern: `check_feature_access` und später ggf. Mapping Widget-ID → Feature-ID(n) / Cluster. |
-| **A2 – Nutzer-Konfigurator** | Im Dashboard-Lab (und jedem späteren Layout-Konfigurator): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
+| **A2 – Nutzer-Konfigurator** | Im Layout-Konfigurator (**Übersicht anpassen**): Widgets **ohne Berechtigung nicht anbieten** (ausgeblendet oder gar nicht in der Liste). Alle **erlaubten** Widgets bleiben wie heute wählbar. |
| **A3 – Layout-Persistenz** | `PUT /api/app/dashboard-layout`: Layout darf **keine** nicht erlaubten Widgets dauerhaft speichern – entweder **ablehnen** (422) oder **beim Speichern entfernen/deaktivieren** (Policy festlegen und dokumentieren). Verhindert „gespeichert, aber nie sichtbar“-Zombies. |
| **A4 – API-/Datenschutz** | Sichtbarkeit im UI reicht nicht: Endpoints, die **Inhalte** für gated Widgets liefern (Charts, KI, …), müssen weiterhin wie heute **eigenständig** über Features abgesichert sein (`check_feature_access`, 403). |
@@ -42,8 +42,8 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
1. **`backend/widget_catalog.py`** – `WIDGET_CATALOG`: erlaubte Widget-IDs, Reihenfolge, Titel/Beschreibung für API und Default-Layout.
2. **`backend/dashboard_layout_schema.py`** – `DashboardLayoutPayload`: jede Zeile hat `id`, `enabled`, optional `config`. IDs müssen in `ALLOWED_WIDGET_IDS` sein (aus dem Katalog abgeleitet).
3. **`backend/dashboard_widget_config.py`** – `validate_widget_entry_config`: **nur** Widgets in `WIDGETS_ALLOWING_CONFIG` dürfen **nicht-leere** `config` haben; Keys werden streng validiert (unbekannte Keys → Fehler).
-4. **Frontend** – `ensurePilotLabWidgetsRegistered()` in `frontend/src/widgetSystem/registerPilotLabWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
-5. **Dashboard-Lab-UI** – `frontend/src/pages/DashboardLabPage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
+4. **Frontend** – `ensureDashboardWidgetsRegistered()` in `frontend/src/widgetSystem/registerDashboardWidgets.js`: verbindet jede Katalog-ID mit einer React-Komponente und mappt `ctx.layoutEntry.config` auf Props.
+5. **Layout-Editor (Produkt)** – `frontend/src/pages/DashboardConfigurePage.jsx`: Umsortieren, Ein/Aus, Speichern; **zusätzliche** UI nur nötig, wenn das Widget konfigurierbare Felder braucht.
---
@@ -52,9 +52,9 @@ Kontext: **Dashboard-Lab** unter geschützten Endpoints `GET/PUT /api/app/...` (
| Schritt | Datei | Aktion |
|--------|--------|--------|
| A | `backend/widget_catalog.py` | Neuen Eintrag `{ "id", "title", "description" }` in `WIDGET_CATALOG` einfügen (Reihenfolge = Default-Reihenfolge im Layout). Optional `"requires_feature": ""` für Tarif-Gating (`dashboard_widget_entitlements`). |
-| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Standard-Lab **aktiv** sein soll. |
-| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Pilot-Komponente) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
-| D | `frontend/src/widgetSystem/registerPilotLabWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
+| B | `backend/widget_catalog.py` | Optional: ID zu `DEFAULT_LAB_WIDGET_IDS` hinzufügen, wenn es im Server-Standardlayout **aktiv** sein soll (Feld `lab_default_layout` in der Layout-API). |
+| C | `frontend/src/components/dashboard-widgets/MyWidget.jsx` (oder Legacy-Widget unter `dashboard-widgets-legacy/`) | React-Komponente implementieren; typischerweise `refreshTick` aus `mapProps` nutzen, um Daten neu zu laden. |
+| D | `frontend/src/widgetSystem/registerDashboardWidgets.js` | `import` + `registerDashboardWidget({ id, Component, mapProps })` – `id` **exakt** wie im Katalog. |
| E | `backend/tests/test_widget_catalog.py` | Läuft implizit mit; bei Strukturänderungen Katalog-Tests beachten. |
| F | `backend/version.py` | `MODULE_VERSIONS["app_dashboard"]` MINOR erhöhen und kurz kommentieren. |
| G | Build/Tests | `pytest` (z. B. `tests/test_dashboard_layout_schema.py`, `test_widget_catalog.py`); `npm run build` im `frontend`. |
@@ -110,11 +110,11 @@ mapProps: (ctx) => ({
**Abgleich mit Chart-Zeitraum:** Für `chart_days` existiert `frontend/src/widgetSystem/bodyChartDays.js` (`BODY_CHART_DAYS_MIN/MAX`, `normalizeBodyChartDays`). Entweder in `mapProps` normalisieren (wie `body_overview`) oder rohen Wert durchreichen und in der Widget-Komponente normalisieren (wie `nutrition_detail_charts` / `TrendKcalWeightWidget`) – **beides** ist im Projekt vertreten; wichtig ist Konsistenz mit der Backend-Grenze 7–90.
-### 3.4 Dashboard-Lab-Editor (`DashboardLabPage.jsx`)
+### 3.4 Layout-Editor (`DashboardConfigurePage.jsx`)
Ohne UI-Änderung bleibt `config` beim Nutzer `{}` – konfigurierbare Widgets brauchen **Editor-Controls**:
-- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in der Datei) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
+- **Einfaches Zahlfeld `chart_days`:** Eintrag in `CHART_DAYS_WIDGET_IDS` (Set oben in `DashboardConfigurePage.jsx`) + bestehendes Label/`aria-label`-Pattern für die Zeitraum-Zeile erweitern (siehe `body_overview`, `nutrition_detail_charts`).
- **Strukturierte Config (Listen, mehrere Booleans):** Eigenes Editor-Komponenten-File nach Vorbild `KpiBoardConfigEditor.jsx` / `QuickCaptureConfigEditor.jsx` einbinden und `setLayout` + `normalizeLayoutForEditor` wie bei den bestehenden Blöcken verwenden.
Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backend validiert über `DashboardLayoutPayload` → `validate_widget_entry_config`.
@@ -137,7 +137,7 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
## 5. API zum Prüfen
- `GET /api/app/widgets/catalog` – Katalog inkl. `allowed` je Widget (Auth + `X-Profile-Id` wie andere App-Endpoints).
-- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Dashboard-Lab-Standard).
+- `GET /api/app/dashboard-layout` – `layout` (effektiv, bereinigt), `custom`, `product_default_layout` (Übersichts-Standard), `lab_default_layout` (Servertemplate für Editor/Reset; Feldname historisch).
- `PUT /api/app/dashboard-layout` – Body `{ "version": 1, "widgets": [ ... ] }` (unerlaubte Widgets werden auf `enabled: false` gesetzt).
---
@@ -159,5 +159,5 @@ Nach Speichern ruft die Seite `api.putAppDashboardLayout(layout)` auf; das Backe
| Layout-Pydantic | `backend/dashboard_layout_schema.py` |
| HTTP | `backend/routers/app_dashboard.py` |
| Registry + Render | `frontend/src/widgetSystem/dashboardWidgetRegistry.jsx` |
-| Pilot/Lab-Registrierung | `frontend/src/widgetSystem/registerPilotLabWidgets.js` |
-| Lab-UI | `frontend/src/pages/DashboardLabPage.jsx` |
+| Dashboard-Widget-Registrierung | `frontend/src/widgetSystem/registerDashboardWidgets.js` |
+| Layout-Editor (Nutzer) | `frontend/src/pages/DashboardConfigurePage.jsx` |
diff --git a/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
new file mode 100644
index 0000000..5f92df2
--- /dev/null
+++ b/.claude/docs/technical/REPORT_PROFILES_AND_PDF.md
@@ -0,0 +1,56 @@
+# Berichtsprofile & PDF (technisch)
+
+**Stand:** 2026-04-29
+
+## Begriffe
+
+| Begriff | Bedeutung |
+|--------|-----------|
+| **Layout-Snapshot** | PDF aus gerasteter DOM-Übersicht (`html2canvas` + `jspdf`), optional Widget `report_export`. |
+| **Strukturierter Bericht** | Profil mit Blöcken (`section`, `chart`, `ai_insight`), PDF serverseitig via Data Layer + Matplotlib + ReportLab. |
+
+Die beiden Wege sind bewusst getrennt, damit das Dashboard nicht die einzige „Wahrheit“ für Dokumente wird.
+
+## Datenbank
+
+- Tabelle `report_profiles` (Migration `060_report_profiles.sql`): `profile_id` PK → `profiles`, `payload` JSONB, `updated_at`.
+
+Ohne Zeile gilt ein **Code-Standard** (`default_report_profile_dict` in `report_profile_schema.py`).
+
+## API (`/api/reports`)
+
+| Methode | Pfad | Zweck |
+|--------|------|--------|
+| GET | `/catalog` | Diagramm-Katalog + Blocktypen für UI |
+| GET | `/profile` | `{ stored, profile }` |
+| PUT | `/profile` | Vollständiges Profil-JSON (Pydantic-validiert) |
+| DELETE | `/profile` | DB-Zeile löschen → wieder Standard |
+| POST | `/generate-pdf` | PDF-Download; `data_export`-Kontingent + `increment_feature_usage` |
+
+## Schema v1 (`report_profile_schema.py`)
+
+- `version`: nur `1`
+- `document_title`: optional
+- `blocks`: Liste mit Union:
+ - `section`: `title`
+ - `chart`: `chart_id` ∈ `ALLOWED_CHART_IDS`, `window_days` 7–365
+ - `ai_insight`: optional `insight_id` (UUID, `ai_insights.id`), optional `title`
+
+## Diagrammdaten
+
+`report_chart_fetch.fetch_chart_payload` ruft dieselben Bausteine auf wie `/api/charts` (ohne HTTP). Erweiterung: Eintrag in `ALLOWED_CHART_IDS`, Fetcher in `_CHART_FETCHERS`, Zeile in `CHART_CATALOG_FOR_API`.
+
+## PDF-Rendering
+
+`report_pdf_render.build_structured_report_pdf`: ReportLab-Flowable-Kette, Diagramme als PNG aus Chart-Payload (Matplotlib, Agg-Backend).
+
+## Frontend
+
+- **Einstellungen:** Karte „PDF-Bericht (strukturiert)“ — Blöcke bearbeiten, speichern, Standard, PDF erzeugen.
+- **Dashboard:** Widget bleibt optionaler **Schnappschuss**; Hinweis verweist auf Einstellungen.
+
+## Nächste sinnvolle Erweiterungen
+
+- Dashboard-Layout → Berichtsprofil **einmalig importieren** (Mapping-Tabelle Widget-ID → chart_id).
+- KI: Insights-Auswahl in der UI statt manueller UUID.
+- Weitere `chart_id`-Werte / multipage Feintuning (Seitenumbrüche pro Block).
diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md
index d5e4e3b..df4b64b 100644
--- a/.claude/rules/ARCHITECTURE.md
+++ b/.claude/rules/ARCHITECTURE.md
@@ -455,15 +455,15 @@ NIEMALS gegen mitai.jinkendo.de
---
-## 10. Dashboard-Lab-Widgets und Feature-System
+## 10. Dashboard-Widgets und Feature-System
-**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, Lab unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
+**Kontext:** Dashboard-Widgets (`backend/widget_catalog.py`, API unter `/api/app/...`) und das **Subscription-/Feature-Modell** (`features`, `tier_limits`, `check_feature_access` in `backend/auth.py`) sind **getrennte Schichten**, müssen aber bei tariffrelevanten Widgets **verknüpft** werden.
**Bindend:**
1. **Keine fest codierten Tier-Namen** für Widget-Rechte – Tiers und Limits kommen aus der DB.
2. **Komplexität** (Module aus, Unter-Stufen, KI vs. Standard) liegt in der **Feature-/Subscription-Logik**, nicht verteilt in Widget-Komponenten.
-3. **Nutzer-Konfigurator** (z. B. Dashboard-Lab): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
+3. **Nutzer-Konfigurator** (**Übersicht anpassen** / `DashboardConfigurePage`): Widgets **ohne** passende Berechtigung **nicht anzeigen**; alle erlaubten Widgets bleiben verfügbar.
4. **Backend** liefert die effektive Erlaubnis (z. B. über erweiterten Katalog oder Entitlements), und **validiert beim Speichern** des Layouts, dass keine unerlaubten Widget-IDs persistiert werden (Policy: ablehnen oder strippen – einheitlich halten).
5. **Daten/API:** Zusätzlich zur UI-Filterung müssen die **inhaltsliefernden Endpoints** weiterhin über `check_feature_access` geschützt sein (kein Leck über direkte API-Aufrufe).
diff --git a/CLAUDE.md b/CLAUDE.md
index 7b3291c..df89feb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@
> | **Gitea-Landkarte (lokal gepflegt)** | **`.claude/docs/GITEA_ISSUES_INDEX.md`** |
> | **Universal CSV Import** (neues Modul / Executor / Vorlagen) | **`.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md`** |
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
-> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
+> | **Dashboard-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
> | **Agent-Einstieg** | **`.claude/README.md`** |
> | **Activity Session Metrics (EAV, Attributprofile)** | **`.claude/docs/technical/ACTIVITY_SESSION_METRICS_EAV_AGENT_GUIDE.md`** |
@@ -100,6 +100,11 @@ frontend/src/
**Branch:** develop
**Nächster Schritt:** Frontend Chart Integration → Testing → Prod Deploy v0.9i
+### Updates (23.04.2026 - Dashboard: veraltete Demo-Route entfernt, klare Produkt-Registry)
+
+- **Frontend:** Veraltete Visualisierungs-Demo-Route und festes Demo-Layout entfernt; Widget-Registrierung in `frontend/src/widgetSystem/registerDashboardWidgets.js` (`ensureDashboardWidgetsRegistered`). Kern-Widgets unter `frontend/src/components/dashboard-widgets-legacy/`. Chart-Hilfen in `frontend/src/widgetSystem/dashboardChartUtils.js`. Experimentelles Layout-Lab entfernt; Konfiguration nur noch **Übersicht anpassen** (`DashboardConfigurePage`).
+- **Doku:** `.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md` und Kommentar in `backend/widget_catalog.py` angepasst.
+
### Updates (09.04.2026 - Universal CSV Import, Prod-Migration abgeschlossen)
- **Agent-Leitfaden:** `.claude/docs/technical/UNIVERSAL_CSV_IMPORT_AGENT_GUIDE.md` (Checkliste für neue Import-Module, Executor, Vorlagen, `source=csv`, SAVEPOINT-/Cursor-Regeln)
@@ -891,7 +896,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
-|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
+|Dashboard-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|Projekt-Doku (Git)|`docs/README.md` + `docs/issues/`|Issue-Specs, Reviews, Platzhalter-Governance, Status-Snapshots|
> Library-Dateien werden mit `/document` generiert und nach größeren
diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py
index 017ab32..df98d5d 100644
--- a/backend/dashboard_layout_schema.py
+++ b/backend/dashboard_layout_schema.py
@@ -1,5 +1,5 @@
"""
-Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard.
+Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Servertemplate (`lab_default_layout_dict`).
Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG.
"""
@@ -32,7 +32,7 @@ __all__ = [
def lab_default_layout_dict() -> dict[str, Any]:
- """Standard für Dashboard-Lab (Experimentier-Widgets)."""
+ """Serverseitiges Standardlayout (DEFAULT_LAB_WIDGET_IDS); API-Feld `lab_default_layout`, u. a. für Editor/Reset."""
on = DEFAULT_LAB_WIDGET_IDS
return {
"version": 1,
diff --git a/backend/dashboard_widget_config.py b/backend/dashboard_widget_config.py
index 9e8a4a0..71e2077 100644
--- a/backend/dashboard_widget_config.py
+++ b/backend/dashboard_widget_config.py
@@ -25,6 +25,7 @@ WIDGETS_ALLOWING_CONFIG: frozenset[str] = frozenset({
"trend_kcal_weight",
"nutrition_detail_charts",
"recovery_charts_panel",
+ "report_export",
})
_QUICK_CAPTURE_KEYS: frozenset[str] = frozenset({
@@ -201,6 +202,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_recovery_history_viz_config({})
if widget_id == "history_overview_viz":
return _validate_history_overview_viz_config({})
+ if widget_id == "report_export":
+ return _validate_report_export_config({})
return {}
if widget_id == "body_overview":
@@ -227,6 +230,8 @@ def validate_widget_entry_config(widget_id: str, raw: Any) -> dict[str, Any]:
return _validate_chart_days_only(raw, label="nutrition_detail_charts")
if widget_id == "recovery_charts_panel":
return _validate_chart_days_only(raw, label="recovery_charts_panel")
+ if widget_id == "report_export":
+ return _validate_report_export_config(raw)
raise ValueError(f"Widget {widget_id}: keine Konfiguration unterstützt")
@@ -530,3 +535,43 @@ def _validate_chart_days_only(raw: dict[str, Any], *, label: str) -> dict[str, A
return {"chart_days": v}
+def _validate_report_export_config(raw: dict[str, Any]) -> dict[str, Any]:
+ label = "report_export"
+ allowed = frozenset({"document_title", "subtitle", "capture_scale"})
+ unknown = set(raw) - allowed
+ if unknown:
+ raise ValueError(f"{label}: unbekannte config-Felder: {sorted(unknown)}")
+ out: dict[str, Any] = {"capture_scale": 2}
+ if "document_title" in raw:
+ t = raw["document_title"]
+ if t is not None and not isinstance(t, str):
+ raise ValueError(f"{label}: document_title muss Text sein")
+ s = (t or "").strip()
+ if len(s) > 120:
+ raise ValueError(f"{label}: document_title max. 120 Zeichen")
+ if s:
+ out["document_title"] = s
+ if "subtitle" in raw:
+ t = raw["subtitle"]
+ if t is not None and not isinstance(t, str):
+ raise ValueError(f"{label}: subtitle muss Text sein")
+ s = (t or "").strip()
+ if len(s) > 240:
+ raise ValueError(f"{label}: subtitle max. 240 Zeichen")
+ if s:
+ out["subtitle"] = s
+ if "capture_scale" in raw:
+ v = raw["capture_scale"]
+ if isinstance(v, bool) or isinstance(v, float):
+ if isinstance(v, float) and math.isfinite(v) and abs(v - round(v)) < 1e-9:
+ v = int(round(v))
+ else:
+ raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
+ if not isinstance(v, int):
+ raise ValueError(f"{label}: capture_scale muss ganze Zahl 1–3 sein")
+ if v < 1 or v > 3:
+ raise ValueError(f"{label}: capture_scale muss zwischen 1 und 3 liegen")
+ out["capture_scale"] = v
+ return out
+
+
diff --git a/backend/main.py b/backend/main.py
index c6ee964..584ff80 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -34,7 +34,8 @@ from routers import workflow_questions # Phase 1 Workflow Engine - Question Cat
from routers import workflows # Phase 2 Workflow Engine - Execution
from routers import reference_values # Persönliche Referenzwerte (Profil)
from routers import admin_reference_value_types # Admin: Referenzwert-Typen
-from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Lab Layout
+from routers import app_dashboard # Geschützter App-Bereich: Dashboard-Layout + Widget-Katalog
+from routers import reports # Strukturierter PDF-Bericht (Profil v1)
from routers import csv_import, admin_csv_templates # Issue #21 Universal CSV Parser
from routers import admin_training_parameters, admin_activity_attribute_profiles # EAV session metrics
@@ -127,6 +128,7 @@ app.include_router(workflows.router) # /api/workflows/* (Phase 2 Exec
app.include_router(reference_values.router) # /api/reference-value-types, /api/profile-reference-values
app.include_router(admin_reference_value_types.router) # /api/admin/reference-value-types
app.include_router(app_dashboard.router) # /api/app/dashboard-layout
+app.include_router(reports.router) # /api/reports/* (Berichtsprofil + PDF)
app.include_router(csv_import.router) # /api/csv/* (Issue #21)
app.include_router(admin_csv_templates.router) # /api/admin/csv-templates/* (Issue #21)
app.include_router(admin_training_parameters.router) # /api/admin/training-parameters
diff --git a/backend/migrations/060_report_profiles.sql b/backend/migrations/060_report_profiles.sql
new file mode 100644
index 0000000..b4f2106
--- /dev/null
+++ b/backend/migrations/060_report_profiles.sql
@@ -0,0 +1,11 @@
+-- Migration 060: Strukturierter Bericht (Profil JSON pro Nutzerprofil, unabhängig vom Dashboard-Layout)
+
+CREATE TABLE IF NOT EXISTS report_profiles (
+ profile_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_report_profiles_updated ON report_profiles(updated_at);
+
+COMMENT ON TABLE report_profiles IS 'Konfigurierbarer PDF-Bericht v1 (Blöcke: section, chart, ai_insight); Rendering serverseitig aus Datenlayer';
diff --git a/backend/migrations/061_report_definitions_multi.sql b/backend/migrations/061_report_definitions_multi.sql
new file mode 100644
index 0000000..af56477
--- /dev/null
+++ b/backend/migrations/061_report_definitions_multi.sql
@@ -0,0 +1,24 @@
+-- Migration 061: Mehrere benannte PDF-Berichte pro Nutzerprofil; Daten von report_profiles übernehmen.
+
+CREATE TABLE IF NOT EXISTS report_definitions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
+ name TEXT NOT NULL DEFAULT 'Bericht',
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
+ sort_order INT NOT NULL DEFAULT 0,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_report_definitions_profile_sort
+ ON report_definitions (profile_id, sort_order);
+
+COMMENT ON TABLE report_definitions IS 'Mehrere strukturierte PDF-Berichte pro Profil (payload = ReportProfilePayload v1)';
+
+INSERT INTO report_definitions (profile_id, name, payload, sort_order)
+SELECT rp.profile_id, 'Standard', rp.payload, 0
+FROM report_profiles rp
+WHERE NOT EXISTS (
+ SELECT 1 FROM report_definitions rd WHERE rd.profile_id = rp.profile_id
+);
+
+DROP TABLE IF EXISTS report_profiles;
diff --git a/backend/report_chart_fetch.py b/backend/report_chart_fetch.py
new file mode 100644
index 0000000..c725b54
--- /dev/null
+++ b/backend/report_chart_fetch.py
@@ -0,0 +1,139 @@
+"""
+Chart-Daten für Berichts-PDF: dieselbe Logik wie /api/charts/* (Data Layer), ohne HTTP.
+"""
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from data_layer.activity_metrics import (
+ build_training_type_distribution_chart_payload,
+ build_training_volume_chart_payload,
+)
+from data_layer.body_metrics import get_weight_trend_data
+from data_layer.nutrition_chart_payloads import build_energy_balance_chart_payload
+from data_layer.nutrition_metrics import get_nutrition_average_data
+from data_layer.utils import serialize_dates
+
+
+def _weight_trend_payload(profile_id: str, days: int) -> dict[str, Any]:
+ d = min(max(days, 7), 365)
+ trend_data = get_weight_trend_data(profile_id, d)
+ if trend_data["confidence"] == "insufficient":
+ return {
+ "chart_type": "line",
+ "data": {"labels": [], "datasets": []},
+ "metadata": {
+ "confidence": "insufficient",
+ "data_points": 0,
+ "message": "Nicht genug Daten für Trend-Analyse",
+ },
+ }
+ series = trend_data.get("series") or []
+ labels = [
+ pt["date"].isoformat() if hasattr(pt["date"], "isoformat") else str(pt["date"]) for pt in series
+ ]
+ values = [pt["weight"] for pt in series]
+ return {
+ "chart_type": "line",
+ "data": {
+ "labels": labels,
+ "datasets": [
+ {
+ "label": "Gewicht",
+ "data": values,
+ "borderColor": "#1D9E75",
+ "backgroundColor": "rgba(29, 158, 117, 0.1)",
+ "borderWidth": 2,
+ "tension": 0.4,
+ "fill": True,
+ "pointRadius": 2,
+ }
+ ],
+ },
+ "metadata": serialize_dates(
+ {
+ "confidence": trend_data["confidence"],
+ "data_points": trend_data["data_points"],
+ "first_value": trend_data["first_value"],
+ "last_value": trend_data["last_value"],
+ "delta": trend_data["delta"],
+ "direction": trend_data["direction"],
+ }
+ ),
+ }
+
+
+def _macro_distribution_payload(profile_id: str, days: int) -> dict[str, Any]:
+ d = min(max(days, 7), 90)
+ macro_data = get_nutrition_average_data(profile_id, d)
+ if macro_data["confidence"] == "insufficient":
+ return {
+ "chart_type": "pie",
+ "data": {"labels": [], "datasets": []},
+ "metadata": {"confidence": "insufficient", "message": "Keine Ernährungsdaten vorhanden"},
+ }
+ protein_kcal = macro_data["protein_avg"] * 4
+ carbs_kcal = macro_data["carbs_avg"] * 4
+ fat_kcal = macro_data["fat_avg"] * 9
+ total_kcal = protein_kcal + carbs_kcal + fat_kcal
+ if total_kcal == 0:
+ return {
+ "chart_type": "pie",
+ "data": {"labels": [], "datasets": []},
+ "metadata": {"confidence": "insufficient", "message": "Keine Makronährstoff-Daten"},
+ }
+ protein_pct = protein_kcal / total_kcal * 100
+ carbs_pct = carbs_kcal / total_kcal * 100
+ fat_pct = fat_kcal / total_kcal * 100
+ return {
+ "chart_type": "pie",
+ "data": {
+ "labels": ["Protein", "Kohlenhydrate", "Fett"],
+ "datasets": [
+ {
+ "data": [round(protein_pct, 1), round(carbs_pct, 1), round(fat_pct, 1)],
+ "backgroundColor": ["#1D9E75", "#F59E0B", "#EF4444"],
+ "borderWidth": 2,
+ "borderColor": "#fff",
+ }
+ ],
+ },
+ "metadata": {"confidence": macro_data.get("confidence", "high")},
+ }
+
+
+def _training_volume_payload(profile_id: str, window_days: int) -> dict[str, Any]:
+ w = max(4, min(52, window_days // 7))
+ return build_training_volume_chart_payload(profile_id, w)
+
+
+_CHART_FETCHERS: dict[str, Callable[[str, int], dict[str, Any]]] = {
+ "weight_trend": _weight_trend_payload,
+ "energy_balance": lambda pid, d: build_energy_balance_chart_payload(pid, min(max(d, 7), 90)),
+ "macro_distribution": _macro_distribution_payload,
+ "training_volume": _training_volume_payload,
+ "training_type_distribution": lambda pid, d: build_training_type_distribution_chart_payload(
+ pid, min(max(d, 7), 90)
+ ),
+}
+
+
+def fetch_chart_payload(chart_id: str, profile_id: str, window_days: int) -> dict[str, Any]:
+ fn = _CHART_FETCHERS.get(chart_id)
+ if not fn:
+ raise ValueError(f"Unbekanntes chart_id: {chart_id}")
+ return fn(profile_id, window_days)
+
+
+CHART_CATALOG_FOR_API: list[dict[str, Any]] = [
+ {"id": "weight_trend", "title": "Gewichtstrend", "default_window_days": 90, "window_max": 365},
+ {"id": "energy_balance", "title": "Energiebilanz", "default_window_days": 28, "window_max": 90},
+ {"id": "macro_distribution", "title": "Makroverteilung (Ø)", "default_window_days": 28, "window_max": 90},
+ {"id": "training_volume", "title": "Trainingsvolumen (Wochen)", "default_window_days": 84, "window_max": 365},
+ {
+ "id": "training_type_distribution",
+ "title": "Trainingsart-Verteilung",
+ "default_window_days": 28,
+ "window_max": 90,
+ },
+]
diff --git a/backend/report_chart_plotting.py b/backend/report_chart_plotting.py
new file mode 100644
index 0000000..2b709de
--- /dev/null
+++ b/backend/report_chart_plotting.py
@@ -0,0 +1,91 @@
+"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt."""
+from __future__ import annotations
+
+import io
+from typing import Any
+
+import matplotlib
+
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+
+
+def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
+ s = (hex_or_rgba or "#333333").strip()
+ if s.startswith("#") and len(s) >= 7:
+ try:
+ r = int(s[1:3], 16) / 255.0
+ g = int(s[3:5], 16) / 255.0
+ b = int(s[5:7], 16) / 255.0
+ return (r, g, b)
+ except ValueError:
+ pass
+ return (0.12, 0.62, 0.46)
+
+
+def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes:
+ chart_type = payload.get("chart_type") or "line"
+ data = payload.get("data") or {}
+ labels = data.get("labels") or []
+ datasets = data.get("datasets") or []
+
+ fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120)
+ ax.set_facecolor("#fafaf9")
+ fig.patch.set_facecolor("#ffffff")
+
+ if chart_type == "pie" and datasets:
+ ds0 = datasets[0]
+ values = ds0.get("data") or []
+ colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"]
+ if labels and values and len(labels) == len(values):
+ ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90)
+ ax.axis("equal")
+ else:
+ ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
+
+ elif chart_type in ("line", "bar", "scatter") and datasets:
+ x = range(len(labels)) if labels else []
+ for i, ds in enumerate(datasets):
+ y = ds.get("data") or []
+ if not y:
+ continue
+ lab = ds.get("label") or f"Serie {i + 1}"
+ col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
+ if chart_type == "bar":
+ yv = y[: len(labels)] if labels else y
+ bg = ds.get("backgroundColor")
+ if isinstance(bg, list):
+ cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
+ else:
+ cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
+ ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
+ else:
+ ax.plot(
+ list(x)[: len(y)],
+ y,
+ label=lab,
+ color=col,
+ linewidth=1.6,
+ marker="o",
+ markersize=2,
+ )
+ if labels and chart_type != "bar":
+ step = max(1, len(labels) // 8)
+ ax.set_xticks(list(x)[::step])
+ ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
+ elif labels and chart_type == "bar":
+ ax.set_xticks(list(x))
+ ax.set_xticklabels(labels, rotation=30, fontsize=7)
+ ax.legend(loc="upper right", fontsize=7)
+ ax.grid(True, alpha=0.25)
+ ax.set_xmargin(0.02)
+
+ else:
+ ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes)
+
+ fig.tight_layout()
+ buf = io.BytesIO()
+ fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor())
+ plt.close(fig)
+ buf.seek(0)
+ return buf.read()
diff --git a/backend/report_pdf_render.py b/backend/report_pdf_render.py
new file mode 100644
index 0000000..0043e54
--- /dev/null
+++ b/backend/report_pdf_render.py
@@ -0,0 +1,130 @@
+"""
+PDF-Bericht aus ReportProfilePayload: ReportLab für Text/Layout, Matplotlib für Chart-Payloads.
+"""
+from __future__ import annotations
+
+import io
+import logging
+from typing import Any
+from xml.sax.saxutils import escape
+
+from reportlab.lib.pagesizes import A4
+from reportlab.lib.styles import getSampleStyleSheet
+from reportlab.lib.units import mm
+from reportlab.platypus import Image as RLImage
+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 _insight_text(profile_id: str, insight_id: str | None) -> tuple[str, str]:
+ """Returns (heading, body_text)."""
+ if not insight_id:
+ return (
+ "KI-Auswertung",
+ "(Noch keine Auswahl — in einer späteren Version kannst du hier eine gespeicherte KI-Analyse "
+ "verknüpfen.)",
+ )
+ try:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT scope, content, created FROM ai_insights WHERE id = %s AND profile_id = %s",
+ (insight_id, profile_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ return ("KI-Auswertung", "Eintrag nicht gefunden oder keine Berechtigung.")
+ scope = row.get("scope") or "Analyse"
+ content = row.get("content") or ""
+ if len(content) > _CONTENT_TRUNCATE:
+ content = content[:_CONTENT_TRUNCATE] + "\n\n[… gekürzt …]"
+ created = row.get("created")
+ sub = f"{scope}" + (f" · {created}" if created else "")
+ return (sub, content)
+ except Exception as e:
+ logger.warning("report pdf insight load failed: %s", e)
+ return ("KI-Auswertung", "Fehler beim Laden des Eintrags.")
+
+
+def build_structured_report_pdf(
+ *,
+ profile_id: str,
+ profile_name: str,
+ payload: ReportProfilePayload,
+) -> bytes:
+ """Vollständiges PDF als Bytes (A4)."""
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(
+ buf,
+ pagesize=A4,
+ leftMargin=14 * mm,
+ rightMargin=14 * mm,
+ topMargin=16 * mm,
+ bottomMargin=16 * mm,
+ )
+ styles = getSampleStyleSheet()
+ story: list[Any] = []
+
+ title = (payload.document_title or "").strip() or f"{profile_name} – Bericht"
+ story.append(Paragraph(escape(title), styles["Title"]))
+ story.append(Spacer(1, 6 * mm))
+
+ for block in payload.blocks:
+ if isinstance(block, SectionBlock):
+ 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)
+ except Exception as e:
+ logger.warning("chart fetch %s: %s", block.chart_id, e)
+ story.append(Paragraph(f"Diagramm {block.chart_id}: Fehler bei Daten.", styles["Normal"]))
+ continue
+ meta = chart.get("metadata") or {}
+ if meta.get("confidence") == "insufficient":
+ msg = meta.get("message") or "Nicht genug Daten"
+ story.append(Paragraph(f"{block.chart_id}: {msg}", styles["Normal"]))
+ story.append(Spacer(1, 3 * mm))
+ continue
+ try:
+ png = chart_payload_to_png(chart)
+ img_buf = io.BytesIO(png)
+ iw = 170 * mm
+ ih = 85 * mm
+ story.append(RLImage(img_buf, width=iw, height=ih))
+ except Exception as e:
+ logger.warning("chart render %s: %s", block.chart_id, e)
+ story.append(Paragraph(f"Diagramm {block.chart_id}: Darstellung fehlgeschlagen.", styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+ elif isinstance(block, AiInsightBlock):
+ heading, body = _insight_text(profile_id, block.insight_id)
+ if block.title.strip():
+ story.append(Paragraph(escape(block.title), styles["Heading3"]))
+ else:
+ story.append(Paragraph(escape(heading), styles["Heading3"]))
+ for para in body.split("\n\n"):
+ p = (para or "").strip()
+ if p:
+ story.append(Paragraph(escape(p), styles["BodyText"]))
+ story.append(Spacer(1, 4 * mm))
+
+ doc.build(story)
+ return buf.getvalue()
diff --git a/backend/report_profile_schema.py b/backend/report_profile_schema.py
new file mode 100644
index 0000000..dc6d706
--- /dev/null
+++ b/backend/report_profile_schema.py
@@ -0,0 +1,126 @@
+"""
+Konfigurierbarer PDF-Bericht v1: Payload-Schema (unabhängig vom Dashboard-Layout).
+
+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 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",
+ "energy_balance",
+ "macro_distribution",
+ "training_volume",
+ "training_type_distribution",
+ }
+)
+
+_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):
+ type: Literal["section"] = "section"
+ title: str = Field(min_length=1, max_length=200)
+
+
+class ChartBlock(BaseModel):
+ type: Literal["chart"] = "chart"
+ chart_id: str = Field(min_length=1, max_length=64)
+ window_days: int = Field(default=28, ge=7, le=365)
+
+ @model_validator(mode="after")
+ def _chart_known(self) -> ChartBlock:
+ if self.chart_id not in ALLOWED_CHART_IDS:
+ raise ValueError(f"Unbekanntes chart_id: {self.chart_id!r} (erlaubt: {sorted(ALLOWED_CHART_IDS)})")
+ return self
+
+
+class AiInsightBlock(BaseModel):
+ type: Literal["ai_insight"] = "ai_insight"
+ title: str = Field(default="", max_length=200)
+ 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, VizBundleBlock]]
+
+ @model_validator(mode="after")
+ def _blocks_limit(self) -> ReportProfilePayload:
+ if len(self.blocks) > _MAX_BLOCKS:
+ raise ValueError(f"Maximal {_MAX_BLOCKS} Blöcke erlaubt")
+ if not self.blocks:
+ raise ValueError("Mindestens ein Block erforderlich")
+ return self
+
+ def to_stored_dict(self) -> dict:
+ return {
+ "version": self.version,
+ "document_title": self.document_title,
+ "blocks": [b.model_dump(mode="json") for b in self.blocks],
+ }
+
+
+def default_report_profile_dict() -> dict:
+ """Standard-Bericht beim ersten Zugriff (ohne DB-Zeile)."""
+ p = ReportProfilePayload(
+ document_title="",
+ blocks=[
+ 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()
+
+
+def parse_report_profile(raw: dict | None) -> ReportProfilePayload:
+ if raw is None or raw == {}:
+ return ReportProfilePayload.model_validate(default_report_profile_dict())
+ return ReportProfilePayload.model_validate(raw)
+
diff --git a/backend/report_viz_bundle_pdf.py b/backend/report_viz_bundle_pdf.py
new file mode 100644
index 0000000..b020ff4
--- /dev/null
+++ b/backend/report_viz_bundle_pdf.py
@@ -0,0 +1,386 @@
+"""
+Layer-2b Verlauf-Bundles → PDF-Abschnitte (KPIs + eingebettete Chart-Payloads).
+
+Gleiche Datenquellen und Config-Validierung wie Dashboard-Widgets (dashboard_widget_config).
+"""
+from __future__ import annotations
+
+import io
+import logging
+from typing import Any
+
+from reportlab.lib.units import mm
+from reportlab.platypus import Image as RLImage
+from reportlab.platypus import Paragraph, Spacer
+from xml.sax.saxutils import escape
+
+from dashboard_widget_config import validate_widget_entry_config
+from data_layer.body_viz import get_body_history_viz_bundle
+from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
+from data_layer.history_overview_viz import get_history_overview_viz_bundle
+from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
+from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
+from data_layer.utils import safe_float
+from report_chart_plotting import chart_payload_to_png
+
+logger = logging.getLogger(__name__)
+
+BUNDLE_HEADINGS: dict[str, str] = {
+ "body_history_viz": "Körper — Kennwerte & Verlauf",
+ "nutrition_history_viz": "Ernährung — Kennwerte & Charts",
+ "fitness_history_viz": "Fitness / Training",
+ "recovery_history_viz": "Erholung & Vitalwerte",
+ "history_overview_viz": "Gesamtübersicht & Korrelationen",
+}
+
+
+def _add_chart_to_story(story: list, styles: dict, payload: dict[str, Any], caption: str | None = None) -> None:
+ meta = payload.get("metadata") or {}
+ if meta.get("confidence") == "insufficient":
+ msg = escape(meta.get("message") or "Keine Daten")
+ story.append(Paragraph(f"{msg}", styles["Normal"]))
+ story.append(Spacer(1, 2 * mm))
+ return
+ if caption:
+ story.append(Paragraph(f"{escape(caption)}", styles["Normal"]))
+ try:
+ png = chart_payload_to_png(payload)
+ story.append(RLImage(io.BytesIO(png), width=170 * mm, height=85 * mm))
+ except Exception as e:
+ logger.warning("bundle chart png: %s", e)
+ story.append(Paragraph("Diagramm konnte nicht gerendert werden.", styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+
+
+def _append_interpretation_tiles(story: list, styles: dict, tiles: list[dict[str, Any]]) -> None:
+ if not tiles:
+ return
+ story.append(Paragraph("Einschätzungen", styles["Heading4"]))
+ for t in tiles:
+ cat = escape(str(t.get("category") or t.get("title") or "—"))
+ title = t.get("title")
+ detail = t.get("detail")
+ val = t.get("value")
+ parts = [f"{cat}"]
+ if title and str(title) != str(cat):
+ parts.append(escape(str(title)))
+ if val is not None and val != "":
+ parts.append(f"({escape(str(val))})")
+ story.append(Paragraph(" — ".join(parts), styles["Normal"]))
+ if detail:
+ story.append(Paragraph(escape(str(detail)[:500]), styles["BodyText"]))
+ story.append(Spacer(1, 3 * mm))
+
+
+def _append_kpi_tiles_fitness_nutreco(story: list, styles: dict, tiles: list[dict[str, Any]], compact: bool) -> None:
+ if not tiles:
+ return
+ use = tiles[:4] if compact else tiles
+ story.append(Paragraph("KPI-Kacheln", styles["Heading4"]))
+ for t in use:
+ cat = escape(str(t.get("category") or t.get("title") or "—"))
+ val = escape(str(t.get("value") or "—"))
+ sub = t.get("sublabel") or t.get("body")
+ line = f"• {cat}: {val}"
+ if sub:
+ line += f" — {escape(str(sub)[:180])}"
+ story.append(Paragraph(line, styles["Normal"]))
+ story.append(Spacer(1, 3 * mm))
+
+
+def _append_insights_lines(story: list, styles: dict, insights: list[dict[str, Any]], label: str) -> None:
+ if not insights:
+ return
+ story.append(Paragraph(f"{escape(label)}", styles["Heading4"]))
+ for item in insights:
+ title = item.get("title") or item.get("heading")
+ body = item.get("body") or item.get("text")
+ if title:
+ story.append(Paragraph(escape(str(title)), styles["Normal"]))
+ if body:
+ story.append(Paragraph(escape(str(body)[:600]), styles["BodyText"]))
+ story.append(Spacer(1, 2 * mm))
+
+
+def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | None:
+ series = bundle_weight.get("series") or []
+ if len(series) < 2:
+ return None
+ labels = [str(p.get("date") or "") for p in series]
+ datasets: list[dict[str, Any]] = [
+ {
+ "label": "Gewicht (kg)",
+ "data": [safe_float(p.get("weight")) for p in series],
+ "borderColor": "#1D9E75",
+ }
+ ]
+ if any(p.get("avg7") is not None for p in series):
+ datasets.append(
+ {
+ "label": "Ø 7T",
+ "data": [safe_float(p.get("avg7")) for p in series],
+ "borderColor": "#378ADD",
+ }
+ )
+ return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}}
+
+
+def _line_payload_from_points(
+ points: list[dict[str, Any]],
+ x_key: str,
+ y_key: str,
+ label: str,
+) -> dict[str, Any] | None:
+ if len(points) < 2:
+ return None
+ labels = [str(p.get(x_key) or "") for p in points]
+ ys = [safe_float(p.get(y_key)) for p in points]
+ return {
+ "chart_type": "line",
+ "data": {
+ "labels": labels,
+ "datasets": [{"label": label, "data": ys, "borderColor": "#1D9E75"}],
+ },
+ "metadata": {"confidence": "high"},
+ }
+
+
+def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
+ days = int(cfg.get("chart_days") or 30)
+ bundle = get_body_history_viz_bundle(profile_id, days)
+ story.append(Paragraph(escape(BUNDLE_HEADINGS["body_history_viz"]), styles["Heading2"]))
+ if bundle.get("confidence") == "insufficient":
+ story.append(Paragraph(escape(bundle.get("message") or "Keine Körperdaten"), styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+ return
+ summ = bundle.get("summary") or {}
+ if summ:
+ w = summ.get("weight_kg")
+ bf = summ.get("body_fat_pct")
+ parts = []
+ if w is not None:
+ parts.append(f"Gewicht: {w} kg")
+ if bf is not None:
+ parts.append(f"KF%: {bf}")
+ if parts:
+ story.append(Paragraph(escape(" · ".join(parts)), styles["Normal"]))
+ story.append(Spacer(1, 2 * mm))
+ if cfg.get("show_kpis", True):
+ _append_interpretation_tiles(story, styles, bundle.get("interpretation_tiles") or [])
+ w = bundle.get("weight") or {}
+ if cfg.get("show_weight_chart", True):
+ pl = _weight_series_payload(w)
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Gewicht")
+ cal = bundle.get("caliper") or {}
+ if cfg.get("show_body_fat_chart", False):
+ ser = cal.get("series") or []
+ pts = [{"date": p.get("date"), "y": p.get("body_fat_pct")} for p in ser if p.get("body_fat_pct") is not None]
+ pl = _line_payload_from_points(pts, "date", "y", "KF %")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Körperfett (Caliper)")
+ circ = bundle.get("circumference") or {}
+ if cfg.get("show_proportion_chart", False):
+ prop = circ.get("proportion_series") or []
+ pts = [{"date": p.get("date"), "y": p.get("v_taper_cm")} for p in prop if p.get("v_taper_cm") is not None]
+ pl = _line_payload_from_points(pts, "date", "y", "V-Taper (cm)")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Proportion (Brust–Taille)")
+ if cfg.get("show_circumference_index_chart", False):
+ idx = circ.get("index_series") or []
+ if len(idx) >= 2:
+ labels = [str(p.get("date") or "") for p in idx]
+ ds: list[dict[str, Any]] = []
+ for key, lab, col in (
+ ("waist_idx", "Taille-Index", "#D85A30"),
+ ("chest_idx", "Brust-Index", "#1D9E75"),
+ ("belly_idx", "Bauch-Index", "#378ADD"),
+ ):
+ ys = [safe_float(p.get(key)) for p in idx]
+ if any(v is not None for v in ys):
+ ds.append({"label": lab, "data": ys, "borderColor": col})
+ if ds:
+ pl = {"chart_type": "line", "data": {"labels": labels, "datasets": ds}, "metadata": {"confidence": "high"}}
+ _add_chart_to_story(story, styles, pl, "Umfang-Indizes")
+ story.append(Spacer(1, 2 * mm))
+
+
+def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
+ days = int(cfg.get("chart_days") or 30)
+ bundle = get_nutrition_history_viz_bundle(profile_id, days)
+ story.append(Paragraph(escape(BUNDLE_HEADINGS["nutrition_history_viz"]), styles["Heading2"]))
+ if not bundle.get("has_nutrition_entries"):
+ story.append(Paragraph(escape(bundle.get("message") or "Keine Ernährungsdaten"), styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+ return
+ compact = cfg.get("kpi_detail") == "compact"
+ if cfg.get("show_kpis", True):
+ _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
+ if cfg.get("show_heuristics", False):
+ h = bundle.get("nutrition_correlation_heuristics") or []
+ for item in h:
+ t = item.get("text") or item.get("title")
+ if t:
+ story.append(Paragraph(f"• {escape(str(t))}", styles["Normal"]))
+ story.append(Spacer(1, 2 * mm))
+ charts = bundle.get("chart_payloads") or {}
+ if cfg.get("show_calorie_balance_chart", False) or cfg.get("show_energy_protein_charts", False):
+ pl = charts.get("energy_balance")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Energiebilanz")
+ if cfg.get("show_energy_protein_charts", False) or cfg.get("show_protein_lean_chart", False):
+ pl = charts.get("protein_adequacy")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Protein-Adäquanz")
+ pl2 = charts.get("nutrition_adherence")
+ if pl2:
+ _add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence")
+ if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False):
+ wm = bundle.get("weekly_macro_chart")
+ if isinstance(wm, dict) and wm.get("chart_type"):
+ _add_chart_to_story(story, styles, wm, "Makros (wöchentlich)")
+ kw = bundle.get("kcal_vs_weight") or {}
+ if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
+ pts = kw["points"]
+ if pts:
+ pl = _line_payload_from_points(pts, "date", "kcal", "kcal")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
+ story.append(Spacer(1, 2 * mm))
+
+
+def _append_fitness_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
+ days = int(cfg.get("chart_days") or 30)
+ bundle = get_fitness_dashboard_viz_bundle(profile_id, days)
+ story.append(Paragraph(escape(BUNDLE_HEADINGS["fitness_history_viz"]), styles["Heading2"]))
+ if not bundle.get("has_activity_entries"):
+ story.append(Paragraph(escape(bundle.get("message") or "Keine Aktivitätsdaten"), styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+ return
+ compact = cfg.get("kpi_detail") == "compact"
+ if cfg.get("show_kpis", True):
+ _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
+ if cfg.get("show_progress_insights", False):
+ _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
+ charts = bundle.get("charts") or {}
+ if cfg.get("show_chart_training_volume", True) and charts.get("training_volume"):
+ _add_chart_to_story(story, styles, charts["training_volume"], "Trainingsvolumen")
+ if cfg.get("show_chart_training_type_distribution", True) and charts.get("training_type_distribution"):
+ _add_chart_to_story(story, styles, charts["training_type_distribution"], "Trainingsarten")
+ if cfg.get("show_chart_quality_sessions", False) and charts.get("quality_sessions"):
+ _add_chart_to_story(story, styles, charts["quality_sessions"], "Qualitätssessions")
+ if cfg.get("show_chart_load_monitoring", False) and charts.get("load_monitoring"):
+ _add_chart_to_story(story, styles, charts["load_monitoring"], "Last / ACWR")
+ story.append(Spacer(1, 2 * mm))
+
+
+def _append_recovery_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
+ days = int(cfg.get("chart_days") or 30)
+ bundle = get_recovery_dashboard_viz_bundle(profile_id, days)
+ story.append(Paragraph(escape(BUNDLE_HEADINGS["recovery_history_viz"]), styles["Heading2"]))
+ if not bundle.get("has_recovery_data"):
+ story.append(Paragraph(escape(bundle.get("message") or "Keine Erholungsdaten"), styles["Normal"]))
+ story.append(Spacer(1, 4 * mm))
+ return
+ compact = cfg.get("kpi_detail") == "compact"
+ if cfg.get("show_kpis", True):
+ _append_kpi_tiles_fitness_nutreco(story, styles, bundle.get("kpi_tiles") or [], compact)
+ if cfg.get("show_progress_insights", False):
+ _append_insights_lines(story, styles, bundle.get("progress_insights") or [], "Einschätzungen")
+ charts = bundle.get("charts") or {}
+ if cfg.get("show_chart_recovery_score", True) and charts.get("recovery_score"):
+ _add_chart_to_story(story, styles, charts["recovery_score"], "Recovery-Score")
+ if cfg.get("show_chart_hrv_rhr", True) and charts.get("hrv_rhr"):
+ _add_chart_to_story(story, styles, charts["hrv_rhr"], "HRV / RHR")
+ if cfg.get("show_chart_sleep_quality", True) and charts.get("sleep_duration_quality"):
+ _add_chart_to_story(story, styles, charts["sleep_duration_quality"], "Schlaf Dauer & Qualität")
+ if cfg.get("show_chart_sleep_debt", False) and charts.get("sleep_debt"):
+ _add_chart_to_story(story, styles, charts["sleep_debt"], "Schlafschuld")
+ if cfg.get("show_vitals_extra_trends", False):
+ if charts.get("vital_signs_matrix"):
+ _add_chart_to_story(story, styles, charts["vital_signs_matrix"], "Vital-Matrix")
+ if charts.get("vitals_history"):
+ _add_chart_to_story(story, styles, charts["vitals_history"], "Vital-Trends")
+ story.append(Spacer(1, 2 * mm))
+
+
+def _append_history_overview_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
+ days = int(cfg.get("chart_days") or 30)
+ bundle = get_history_overview_viz_bundle(profile_id, days)
+ story.append(Paragraph(escape(BUNDLE_HEADINGS["history_overview_viz"]), styles["Heading2"]))
+ sect_keys = {
+ "body": cfg.get("show_section_body", True),
+ "nutrition": cfg.get("show_section_nutrition", True),
+ "fitness": cfg.get("show_section_fitness", True),
+ "recovery": cfg.get("show_section_recovery", True),
+ }
+ for sec in bundle.get("sections") or []:
+ sid = sec.get("id")
+ if not sect_keys.get(str(sid), True):
+ continue
+ title = escape(str(sec.get("title") or sid))
+ line = escape(str(sec.get("summary_line") or ""))
+ story.append(Paragraph(f"{title}: {line}", styles["Normal"]))
+ for it in sec.get("interpretation_short") or []:
+ t = it.get("title") if isinstance(it, dict) else None
+ if t:
+ story.append(Paragraph(f"• {escape(str(t))}", styles["BodyText"]))
+ for k in sec.get("kpi_short") or []:
+ if isinstance(k, dict):
+ cat = k.get("category") or k.get("title")
+ val = k.get("value")
+ if cat:
+ story.append(Paragraph(f"• {escape(str(cat))}: {escape(str(val or ''))}", styles["BodyText"]))
+ story.append(Spacer(1, 2 * mm))
+ if cfg.get("show_correlation_c1_c3", True) or cfg.get("show_drivers_c4", True):
+ lag = bundle.get("lag_correlations") or {}
+ we = lag.get("weight_energy") or {}
+ if we.get("available") and (we.get("interpretation") or we.get("label")):
+ lab = escape(str(we.get("label") or "C1"))
+ interp = escape(str(we.get("interpretation") or "").strip())
+ if interp:
+ story.append(Paragraph(f"{lab}: {interp}", styles["Normal"]))
+ charts = bundle.get("chart_payloads") or {}
+ if cfg.get("show_correlation_c1_c3", True):
+ for key, cap in (
+ ("c1_weight_energy", "Korrelation Gewicht / Energie"),
+ ("c2_protein_lbm", "Protein / Magermasse"),
+ ("c3_load_vitals", "Last / Vitalwerte"),
+ ):
+ pl = charts.get(key)
+ if pl:
+ _add_chart_to_story(story, styles, pl, cap)
+ if cfg.get("show_drivers_c4", True):
+ pl = charts.get("c4_recovery_performance")
+ if pl:
+ _add_chart_to_story(story, styles, pl, "Top-Treiber")
+ drv = (bundle.get("lag_correlations") or {}).get("recovery_performance") or {}
+ for d in (drv.get("drivers") or [])[:12]:
+ if isinstance(d, dict):
+ lab = d.get("label") or d.get("factor")
+ val = d.get("impact") or d.get("score")
+ if lab:
+ story.append(Paragraph(f"• {escape(str(lab))}: {escape(str(val or ''))}", styles["Normal"]))
+ story.append(Spacer(1, 2 * mm))
+
+
+def append_viz_bundle_to_story(
+ story: list,
+ styles: dict,
+ profile_id: str,
+ bundle_id: str,
+ raw_config: dict[str, Any],
+) -> None:
+ cfg = validate_widget_entry_config(bundle_id, raw_config)
+ if bundle_id == "body_history_viz":
+ _append_body_bundle(story, styles, profile_id, cfg)
+ elif bundle_id == "nutrition_history_viz":
+ _append_nutrition_bundle(story, styles, profile_id, cfg)
+ elif bundle_id == "fitness_history_viz":
+ _append_fitness_bundle(story, styles, profile_id, cfg)
+ elif bundle_id == "recovery_history_viz":
+ _append_recovery_bundle(story, styles, profile_id, cfg)
+ elif bundle_id == "history_overview_viz":
+ _append_history_overview_bundle(story, styles, profile_id, cfg)
+ else:
+ story.append(Paragraph(escape(f"Unbekanntes Bundle: {bundle_id}"), styles["Normal"]))
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 445b62d..d435df3 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -10,3 +10,5 @@ slowapi==0.1.9
psycopg2-binary==2.9.9
python-dateutil==2.9.0
tzdata>=2024.1 # ZoneInfo (Europe/Berlin) auch unter Windows
+matplotlib==3.8.4
+reportlab==4.2.0
diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py
index fe8fdc5..36abf38 100644
--- a/backend/routers/app_dashboard.py
+++ b/backend/routers/app_dashboard.py
@@ -1,5 +1,5 @@
"""
-Geschützter App-Bereich: Dashboard-Lab Layout (kein Produktiv-Dashboard).
+Geschützter App-Bereich: Dashboard-Layout und Widget-Katalog.
/api/app/dashboard-layout — nur mit Session + aktivem Profil (X-Profile-Id).
"""
@@ -20,7 +20,7 @@ from db import get_cursor, get_db
from routers.profiles import get_pid
from system_dashboard_product_default import get_product_default_base_dict
-router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
+router = APIRouter(prefix="/api/app", tags=["app-dashboard"])
@router.get("/widgets/catalog")
diff --git a/backend/routers/reports.py b/backend/routers/reports.py
new file mode 100644
index 0000000..b6a9fcd
--- /dev/null
+++ b/backend/routers/reports.py
@@ -0,0 +1,324 @@
+"""
+Strukturierter PDF-Bericht: mehrere Definitionen pro Profil, Katalog, PDF-Erzeugung.
+
+PDF-Zähler: data_export (wie andere Exporte).
+"""
+from __future__ import annotations
+
+import logging
+from datetime import datetime
+from uuid import UUID
+
+from fastapi import APIRouter, Body, Depends, HTTPException
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+from psycopg2.extras import Json
+
+from auth import check_feature_access, increment_feature_usage, require_auth
+from db import get_cursor, get_db
+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,
+)
+
+router = APIRouter(prefix="/api/reports", tags=["reports"])
+logger = logging.getLogger(__name__)
+
+_MAX_REPORT_DEFINITIONS = 20
+
+
+class CreateReportDefinitionBody(BaseModel):
+ name: str = Field(default="Neuer Bericht", min_length=1, max_length=120)
+
+
+class UpdateReportDefinitionBody(BaseModel):
+ name: str | None = Field(default=None, min_length=1, max_length=120)
+ payload: dict | None = None
+
+
+class GeneratePdfRequest(BaseModel):
+ definition_id: UUID | None = None
+
+
+def _profile_display_name(profile_id: str) -> str:
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute("SELECT name FROM profiles WHERE id = %s", (profile_id,))
+ row = cur.fetchone()
+ if not row:
+ return "Profil"
+ return (row.get("name") or "Profil").strip() or "Profil"
+
+
+def _row_to_definition(row: dict) -> dict:
+ pl = row.get("payload")
+ if not isinstance(pl, dict):
+ pl = {}
+ return {
+ "id": str(row["id"]),
+ "name": (row.get("name") or "Bericht").strip() or "Bericht",
+ "sort_order": int(row.get("sort_order") or 0),
+ "updated_at": row.get("updated_at").isoformat() if row.get("updated_at") else None,
+ "payload": pl,
+ }
+
+
+@router.get("/catalog")
+def get_reports_catalog(session: dict = Depends(require_auth)):
+ """Metadaten für UI: verfügbare Diagramme, Bundles, Zeitraumgrenzen."""
+ 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": 3,
+ "chart_days": {"min": 7, "max": 90},
+ "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": "viz_bundle", "title": "Verlauf-Bundle (KPIs & Charts)"},
+ {"id": "chart", "title": "Einzel-Diagramm (Legacy)"},
+ {"id": "ai_insight", "title": "KI-Auswertung"},
+ ],
+ }
+
+
+@router.get("/definitions")
+def list_report_definitions(session: dict = Depends(require_auth)):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ """
+ SELECT id, name, sort_order, updated_at, payload
+ FROM report_definitions
+ WHERE profile_id = %s
+ ORDER BY sort_order ASC, name ASC, updated_at DESC
+ """,
+ (pid,),
+ )
+ rows = cur.fetchall()
+ return {"definitions": [_row_to_definition(dict(r)) for r in rows]}
+
+
+@router.post("/definitions")
+def create_report_definition(
+ body: CreateReportDefinitionBody | None = Body(default=None),
+ session: dict = Depends(require_auth),
+):
+ req = body or CreateReportDefinitionBody()
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT COUNT(*) AS n FROM report_definitions WHERE profile_id = %s",
+ (pid,),
+ )
+ n = int((cur.fetchone() or {}).get("n") or 0)
+ if n >= _MAX_REPORT_DEFINITIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Maximal {_MAX_REPORT_DEFINITIONS} Berichte erlaubt.",
+ )
+
+ payload_dict = default_report_profile_dict()
+ payload_dict["document_title"] = ""
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_so FROM report_definitions WHERE profile_id = %s",
+ (pid,),
+ )
+ next_so = int((cur.fetchone() or {}).get("next_so") or 0)
+ cur.execute(
+ """
+ INSERT INTO report_definitions (profile_id, name, payload, sort_order, updated_at)
+ VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)
+ RETURNING id, name, sort_order, updated_at, payload
+ """,
+ (pid, req.name.strip(), Json(payload_dict), next_so),
+ )
+ row = cur.fetchone()
+ conn.commit()
+
+ if not row:
+ raise HTTPException(status_code=500, detail="Bericht konnte nicht angelegt werden.")
+ return {"definition": _row_to_definition(dict(row))}
+
+
+@router.put("/definitions/{definition_id}")
+def update_report_definition(
+ definition_id: UUID,
+ body: UpdateReportDefinitionBody,
+ session: dict = Depends(require_auth),
+):
+ pid = session["profile_id"]
+ name = body.name.strip() if body.name else None
+ parsed_payload: ReportProfilePayload | None = None
+ if body.payload is not None:
+ try:
+ parsed_payload = ReportProfilePayload.model_validate(body.payload)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ if parsed_payload is not None and name is not None:
+ cur.execute(
+ """
+ UPDATE report_definitions
+ SET name = %s, payload = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND profile_id = %s
+ RETURNING id, name, sort_order, updated_at, payload
+ """,
+ (name, Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
+ )
+ elif parsed_payload is not None:
+ cur.execute(
+ """
+ UPDATE report_definitions
+ SET payload = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND profile_id = %s
+ RETURNING id, name, sort_order, updated_at, payload
+ """,
+ (Json(parsed_payload.to_stored_dict()), str(definition_id), pid),
+ )
+ elif name is not None:
+ cur.execute(
+ """
+ UPDATE report_definitions
+ SET name = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND profile_id = %s
+ RETURNING id, name, sort_order, updated_at, payload
+ """,
+ (name, str(definition_id), pid),
+ )
+ else:
+ raise HTTPException(status_code=400, detail="Nichts zu aktualisieren (name oder payload fehlt).")
+
+ row = cur.fetchone()
+ conn.commit()
+
+ if not row:
+ raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
+ return {"definition": _row_to_definition(dict(row))}
+
+
+@router.delete("/definitions/{definition_id}")
+def delete_report_definition(definition_id: UUID, session: dict = Depends(require_auth)):
+ pid = session["profile_id"]
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ cur.execute(
+ "DELETE FROM report_definitions WHERE id = %s AND profile_id = %s RETURNING id",
+ (str(definition_id), pid),
+ )
+ deleted = cur.fetchone()
+ conn.commit()
+ if not deleted:
+ raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
+ return {"ok": True}
+
+
+def _fetch_definition_payload(profile_id: str, definition_id: UUID | None) -> tuple[dict, str]:
+ """Returns (raw_payload_dict, report_label_for_filename)."""
+ with get_db() as conn:
+ cur = get_cursor(conn)
+ if definition_id is not None:
+ cur.execute(
+ """
+ SELECT payload, name FROM report_definitions
+ WHERE id = %s AND profile_id = %s
+ """,
+ (str(definition_id), profile_id),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Bericht nicht gefunden.")
+ pl = row.get("payload")
+ label = (row.get("name") or "Bericht").strip() or "Bericht"
+ if not isinstance(pl, dict):
+ raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
+ return pl, label
+
+ cur.execute(
+ """
+ SELECT payload, name FROM report_definitions
+ WHERE profile_id = %s
+ ORDER BY sort_order ASC, name ASC, updated_at DESC
+ LIMIT 1
+ """,
+ (profile_id,),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(
+ status_code=400,
+ detail="Kein Bericht angelegt — bitte unter Einstellungen › PDF-Berichte einen Bericht erstellen.",
+ )
+ pl = row.get("payload")
+ label = (row.get("name") or "Bericht").strip() or "Bericht"
+ if not isinstance(pl, dict):
+ raise HTTPException(status_code=400, detail="Ungültige Berichtsdaten.")
+ return pl, label
+
+
+@router.post("/generate-pdf")
+def generate_structured_report_pdf(
+ body: GeneratePdfRequest | None = Body(default=None),
+ session: dict = Depends(require_auth),
+):
+ pid = session["profile_id"]
+ req = body or GeneratePdfRequest()
+
+ access = check_feature_access(pid, "data_export")
+ log_feature_usage(pid, "data_export", access, "report_generate_pdf")
+ if not access["allowed"]:
+ logger.warning(
+ "[FEATURE-LIMIT] report pdf blocked: %s used=%s limit=%s",
+ pid,
+ access.get("used"),
+ access.get("limit"),
+ )
+ raise HTTPException(
+ status_code=403,
+ detail=(
+ "Limit erreicht: Daten-Export nicht möglich "
+ f"({access.get('used')}/{access.get('limit')})."
+ ),
+ )
+
+ raw, report_label = _fetch_definition_payload(pid, req.definition_id)
+ try:
+ payload = parse_report_profile(raw)
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"Berichtsprofil ungültig: {e}")
+
+ profile_name = _profile_display_name(pid)
+ try:
+ pdf_bytes = build_structured_report_pdf(profile_id=pid, profile_name=profile_name, payload=payload)
+ except Exception as e:
+ logger.exception("report pdf build failed")
+ raise HTTPException(status_code=500, detail=f"PDF-Erzeugung fehlgeschlagen: {e}")
+
+ increment_feature_usage(pid, "data_export")
+
+ doc_title = (payload.document_title or "").strip()
+ base_label = doc_title or report_label
+ safe_name = "".join(c for c in base_label if c.isalnum() or c in (" ", "-", "_")).strip() or "bericht"
+ fn = f"mitai-bericht-{safe_name.replace(' ', '-')}-{datetime.now().strftime('%Y-%m-%d')}.pdf"
+ return Response(
+ content=pdf_bytes,
+ media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{fn}"'},
+ )
diff --git a/backend/tests/test_report_profile_schema.py b/backend/tests/test_report_profile_schema.py
new file mode 100644
index 0000000..22599cb
--- /dev/null
+++ b/backend/tests/test_report_profile_schema.py
@@ -0,0 +1,56 @@
+"""Berichtsprofil-Schema: Defaults und Validierung."""
+
+from report_profile_schema import (
+ ReportProfilePayload,
+ default_report_profile_dict,
+ parse_report_profile,
+)
+
+
+def test_default_profile_roundtrip():
+ d = default_report_profile_dict()
+ p = ReportProfilePayload.model_validate(d)
+ assert p.version == 1
+ assert len(p.blocks) >= 3
+
+
+def test_parse_empty_uses_default():
+ p = parse_report_profile({})
+ assert len(p.blocks) >= 1
+
+
+def test_chart_block_unknown_id_raises():
+ import pytest
+
+ raw = {
+ "version": 1,
+ "document_title": "",
+ "blocks": [{"type": "chart", "chart_id": "not_a_chart", "window_days": 28}],
+ }
+ with pytest.raises(Exception):
+ ReportProfilePayload.model_validate(raw)
+
+
+def test_viz_bundle_roundtrip():
+ raw = {
+ "version": 1,
+ "document_title": "",
+ "blocks": [
+ {"type": "viz_bundle", "bundle_id": "body_history_viz", "config": {"chart_days": 14}},
+ ],
+ }
+ p = ReportProfilePayload.model_validate(raw)
+ assert p.blocks[0].type == "viz_bundle"
+ assert p.blocks[0].config.get("chart_days") == 14
+
+
+def test_viz_bundle_unknown_raises():
+ import pytest
+
+ raw = {
+ "version": 1,
+ "document_title": "",
+ "blocks": [{"type": "viz_bundle", "bundle_id": "not_a_bundle", "config": {}}],
+ }
+ with pytest.raises(Exception):
+ ReportProfilePayload.model_validate(raw)
diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py
index e83720d..5dc3986 100644
--- a/backend/widget_catalog.py
+++ b/backend/widget_catalog.py
@@ -1,8 +1,8 @@
"""
-Öffentlicher Widget-Katalog (Dashboard-Lab / später Produkt-Dashboard).
+Öffentlicher Widget-Katalog (konfigurierbare Übersicht / API).
Single Source für: erlaubte IDs, Standard-Reihenfolge, Anzeige-Metadaten für API/GUI.
-Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerPilotLabWidgets).
+Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/registerDashboardWidgets.js, Funktion ensureDashboardWidgetsRegistered).
"""
from __future__ import annotations
@@ -149,6 +149,12 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
"requires_feature": "ai_pipeline",
},
+ {
+ "id": "report_export",
+ "title": "Übersicht als Bild-PDF",
+ "description": "Raster-PDF der Startübersicht (html2canvas); für strukturierten Datenbericht siehe Einstellungen. Optional document_title, subtitle, capture_scale; data_export",
+ "requires_feature": "data_export",
+ },
]
DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6c0c00d..ecfb10e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.0.0",
"dependencies": {
"dayjs": "^1.11.11",
+ "html2canvas": "^1.4.1",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0",
@@ -3147,7 +3148,6 @@
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
- "optional": true,
"engines": {
"node": ">= 0.6.0"
}
@@ -3418,7 +3418,6 @@
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
- "optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@@ -4454,7 +4453,6 @@
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
- "optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
@@ -6320,7 +6318,6 @@
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
- "optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
@@ -6591,7 +6588,6 @@
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
- "optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
diff --git a/frontend/package.json b/frontend/package.json
index fd1d304..bb45e90 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"dayjs": "^1.11.11",
+ "html2canvas": "^1.4.1",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"lucide-react": "^0.383.0",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 4e397fe..089d534 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -25,9 +25,7 @@ import Analysis from './pages/Analysis'
import SettingsPage from './pages/SettingsPage'
import SettingsShell from './layouts/SettingsShell'
import ProfileReferenceValuesPage from './pages/ProfileReferenceValuesPage'
-import PilotVizPage from './pages/PilotVizPage'
-import DashboardLabPage from './pages/DashboardLabPage'
-import DashboardConfigurePage from './pages/DashboardConfigurePage'
+import ReportConfigurePage from './pages/ReportConfigurePage'
import GuidePage from './pages/GuidePage'
import AdminTierLimitsPage from './pages/AdminTierLimitsPage'
import AdminFeaturesPage from './pages/AdminFeaturesPage'
@@ -243,6 +241,7 @@ function AppShell() {
} />
} />
} />
+ } />
}>
}>
@@ -271,8 +270,6 @@ function AppShell() {
}/>
}/>
- } />
- } />
diff --git a/frontend/src/components/pilot/PilotActivitySection.jsx b/frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx
similarity index 94%
rename from frontend/src/components/pilot/PilotActivitySection.jsx
rename to frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx
index f4e23a6..a8f912a 100644
--- a/frontend/src/components/pilot/PilotActivitySection.jsx
+++ b/frontend/src/components/dashboard-widgets-legacy/ActivityOverviewWidget.jsx
@@ -8,9 +8,9 @@ import {
BODY_CHART_DAYS_DEFAULT,
normalizeBodyChartDays,
} from '../../widgetSystem/bodyChartDays'
-import PilotRuleCard from './PilotRuleCard'
+import DashboardRuleCard from './DashboardRuleCard'
-export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
+export default function ActivityOverviewWidget({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
const periodDays = normalizeBodyChartDays(chartDays)
const { activeProfile } = useProfile()
const globalQualityLevel = activeProfile?.quality_filter_level
@@ -124,7 +124,7 @@ export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY
) : (
- actRules.map((item, i) => )
+ actRules.map((item, i) => )
)}
diff --git a/frontend/src/components/pilot/PilotBodySection.jsx b/frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx
similarity index 96%
rename from frontend/src/components/pilot/PilotBodySection.jsx
rename to frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx
index 18a29f6..3ee41c0 100644
--- a/frontend/src/components/pilot/PilotBodySection.jsx
+++ b/frontend/src/components/dashboard-widgets-legacy/BodyOverviewWidget.jsx
@@ -15,14 +15,14 @@ import dayjs from 'dayjs'
import { api } from '../../utils/api'
import { useProfile } from '../../context/ProfileContext'
import { getInterpretation } from '../../utils/interpret'
-import { rollingAvg, fmtDate } from '../../pilot/pilotChartUtils'
+import { rollingAvg, fmtDate } from '../../widgetSystem/dashboardChartUtils'
import {
BODY_CHART_DAYS_DEFAULT,
normalizeBodyChartDays,
} from '../../widgetSystem/bodyChartDays'
-import PilotRuleCard from './PilotRuleCard'
+import DashboardRuleCard from './DashboardRuleCard'
-export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
+export default function BodyOverviewWidget({ refreshTick = 0, chartDays = BODY_CHART_DAYS_DEFAULT }) {
const windowDays = normalizeBodyChartDays(chartDays)
const { activeProfile } = useProfile()
const [weights, setWeights] = useState([])
@@ -221,7 +221,7 @@ export default function PilotBodySection({ refreshTick = 0, chartDays = BODY_CHA
Körperfett, Magermasse (FFMI), BMI – gleiche Logik wie auf der Verlauf-Seite (Körper).
{rules.map((item, i) => (
-
+
))}
)}
diff --git a/frontend/src/components/pilot/PilotRuleCard.jsx b/frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx
similarity index 97%
rename from frontend/src/components/pilot/PilotRuleCard.jsx
rename to frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx
index 55bcc6f..c857148 100644
--- a/frontend/src/components/pilot/PilotRuleCard.jsx
+++ b/frontend/src/components/dashboard-widgets-legacy/DashboardRuleCard.jsx
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { getStatusColor, getStatusBg } from '../../utils/interpret'
-export default function PilotRuleCard({ item }) {
+export default function DashboardRuleCard({ item }) {
const [open, setOpen] = useState(false)
const color = getStatusColor(item.status)
return (
diff --git a/frontend/src/components/pilot/PilotKpiBoard.jsx b/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx
similarity index 99%
rename from frontend/src/components/pilot/PilotKpiBoard.jsx
rename to frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx
index b98e60c..0f4bd00 100644
--- a/frontend/src/components/pilot/PilotKpiBoard.jsx
+++ b/frontend/src/components/dashboard-widgets-legacy/KpiBoardWidget.jsx
@@ -38,7 +38,7 @@ function buildAutoTileIds(refTiles, hasBf, hasKcal) {
* @param {{ refreshTick?: number, kpiConfig?: Record }} props
* kpiConfig.tiles: geordnete Kachel-ids; fehlend = automatische Belegung (wie bisher).
*/
-export default function PilotKpiBoard({ refreshTick = 0, kpiConfig }) {
+export default function KpiBoardWidget({ refreshTick = 0, kpiConfig }) {
const manualOrder = useMemo(() => kpiTileOrderFromConfig(kpiConfig), [kpiConfig])
const { activeProfile } = useProfile()
diff --git a/frontend/src/components/pilot/PilotQuickCapture.jsx b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx
similarity index 95%
rename from frontend/src/components/pilot/PilotQuickCapture.jsx
rename to frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx
index 2854bee..2fc8938 100644
--- a/frontend/src/components/pilot/PilotQuickCapture.jsx
+++ b/frontend/src/components/dashboard-widgets-legacy/QuickCaptureWidget.jsx
@@ -9,7 +9,7 @@ import { api } from '../../utils/api'
* @param {{ onSaved?: () => void, captureConfig?: Record }} props
* captureConfig: show_weight, show_resting_hr, show_hrv, show_vo2_max (false = ausblenden; fehlend = true)
*/
-export default function PilotQuickCapture({ onSaved, captureConfig }) {
+export default function QuickCaptureWidget({ onSaved, captureConfig }) {
const cfgRaw = captureConfig && typeof captureConfig === 'object' ? captureConfig : {}
const showWeight = cfgRaw.show_weight !== false
const showRestingHr = cfgRaw.show_resting_hr !== false
@@ -130,8 +130,9 @@ export default function PilotQuickCapture({ onSaved, captureConfig }) {
Schnelleingabe (heute)
- Für dieses Widget sind keine Eingabebereiche aktiviert. Im Dashboard-Lab die Sichtbarkeit prüfen
- oder Vitalwerte-Seite nutzen.
+ Für dieses Widget sind keine Eingabebereiche aktiviert. Unter{' '}
+ Übersicht anpassen die Schnelleingabe-Konfiguration prüfen oder{' '}
+ Vitalwerte-Seite nutzen.
+ Layout-Schnappschuss: Die sichtbare Übersicht wird im Browser gerastert (html2canvas).
+ Für einen datenbasierten Bericht unabhängig vom Dashboard öffne{' '}
+
+ Einstellungen → PDF-Berichte
+
+ .
+
+
+ {!canExport ? (
+
+ PDF-Export ist für dieses Profil nicht freigeschaltet.
+
+ ) : (
+ <>
+ {err && (
+
+ {err}
+
+ )}
+
+ >
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/config/settingsNav.js b/frontend/src/config/settingsNav.js
index f03cc3f..e645f58 100644
--- a/frontend/src/config/settingsNav.js
+++ b/frontend/src/config/settingsNav.js
@@ -6,5 +6,6 @@
export const SETTINGS_SHELL_NAV_ITEMS = [
{ id: 'general', label: 'Allgemein', to: '/settings', end: true },
{ id: 'dashboard-layout', label: 'Übersicht', to: '/settings/dashboard-layout' },
+ { id: 'reports', label: 'PDF-Berichte', to: '/settings/reports' },
{ id: 'reference-values', label: 'Referenzwerte', to: '/settings/reference-values' },
]
diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx
index c8d9d2d..384e3a1 100644
--- a/frontend/src/pages/Dashboard.jsx
+++ b/frontend/src/pages/Dashboard.jsx
@@ -5,7 +5,7 @@ import { api } from '../utils/api'
import { useProfile } from '../context/ProfileContext'
import TrialBanner from '../components/TrialBanner'
import EmailVerificationBanner from '../components/EmailVerificationBanner'
-import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
+import { ensureDashboardWidgetsRegistered } from '../widgetSystem/registerDashboardWidgets'
import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
function catalogMetaById(catalog) {
@@ -27,7 +27,7 @@ export default function Dashboard() {
const requestRefresh = () => setRefreshTick((t) => t + 1)
useEffect(() => {
- ensurePilotLabWidgetsRegistered()
+ ensureDashboardWidgetsRegistered()
}, [])
useEffect(() => {
@@ -123,7 +123,9 @@ export default function Dashboard() {
)}
{!layoutLoading && layoutForPreview && (
-
+
- )
-}
diff --git a/frontend/src/pages/PilotVizPage.jsx b/frontend/src/pages/PilotVizPage.jsx
deleted file mode 100644
index a45351b..0000000
--- a/frontend/src/pages/PilotVizPage.jsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useState } from 'react'
-import { FlaskConical } from 'lucide-react'
-import { Link } from 'react-router-dom'
-import { WidgetRenderer } from '../widgetSystem/dashboardWidgetRegistry'
-import { ensurePilotLabWidgetsRegistered } from '../widgetSystem/registerPilotLabWidgets'
-import { DEFAULT_LAB_LAYOUT } from '../widgetSystem/defaultLabLayout'
-
-/**
- * Pilot-Übersicht nach Product-Spec (festes Standard-Layout).
- * Nutzt dasselbe Widget-Rendering wie /app/dashboard-lab.
- */
-export default function PilotVizPage() {
- ensurePilotLabWidgetsRegistered()
-
- const [refreshTick, setRefreshTick] = useState(0)
- const requestRefresh = () => setRefreshTick((t) => t + 1)
-
- return (
-
-
-
- ← Einstellungen
-
-
-
- Pilot: Übersicht
-
-
- Konfigurierbare Ziel-Übersicht (Test). Produktives Dashboard und Verlauf unverändert. Nach Speichern von
- Gewicht oder Vitalwerten werden KPIs und Körperbereich neu geladen.
-
+ Hier legst du einen oder mehrere strukturierte PDF-Berichte an. Pro Block vom Typ
+ „Verlauf-Bundle“ gilt ein Zeitraum in Tagen (wie bei der Übersicht).{' '}
+ Technisch: Es sind dieselben{' '}
+ Daten-Bundles und dieselbe Konfiguration wie bei den Verlauf-Widgets — im PDF werden
+ sie serverseitig gerendert (nicht die React-Komponenten der Startseite).
+
+ {!canExport && (
+
+ PDF ist mit dem Kontingent „Datenexport“ verknüpft. Bitte Admin kontaktieren.
+
+ Mehrere strukturierte Berichte, Zeiträume pro Verlauf-Bundle und PDF-Erzeugung findest du im
+ eigenen Bereich — analog „Übersicht“ in den Einstellungen.
+
+ {!canExport && (
+
+ PDF nutzt dasselbe Kontingent wie Datenexporte. Bitte Admin kontaktieren.
+
+ )}
+ {canExport && (
+
+ Zu den PDF-Berichten
+
+ )}
+