- Introduced the `report_export` widget to the dashboard, allowing users to generate structured PDF reports. - Updated widget configuration to include `report_export` in the allowed widgets and added validation for its configuration. - Enhanced the widget catalog with details for the new `report_export` entry. - Implemented API endpoints for managing report profiles and generating PDFs. - Added frontend components for configuring and displaying report settings. - Updated tests to ensure proper validation and functionality of the new report generation features. - Bumped application version to reflect the addition of the new widget and related functionalities.
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""
|
|
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,
|
|
},
|
|
]
|