feat: enhance fitness dashboard with new metrics and insights
- Refactored the `calculate_proxy_internal_load_7d` function to `calculate_proxy_internal_load_window`, allowing for dynamic day range input. - Introduced new functions for calculating training volume deltas and building fitness progress insights, enhancing user feedback on training metrics. - Updated the fitness dashboard to include new charts for quality sessions and load monitoring, improving data visualization. - Integrated these new metrics into the fitness dashboard overview, providing users with comprehensive insights into their training performance. - Streamlined the router to utilize the new chart-building functions, ensuring consistency and maintainability across the application.
This commit is contained in:
parent
22c5f695c9
commit
bf84e3c2a5
|
|
@ -501,11 +501,12 @@ def calculate_ability_balance_mobility(profile_id: str) -> Optional[int]:
|
|||
# A5: Load Monitoring (Proxy-based)
|
||||
# ============================================================================
|
||||
|
||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
||||
def calculate_proxy_internal_load_window(profile_id: str, days: int = 7) -> Optional[float]:
|
||||
"""
|
||||
Calculate proxy internal load (last 7 days)
|
||||
Formula: duration × intensity_factor × quality_factor
|
||||
Proxy-Last über die letzten ``days`` Kalendertage (gleiche Formel wie bisher nur für 7 Tage).
|
||||
"""
|
||||
if days < 1:
|
||||
days = 7
|
||||
intensity_factors = {'low': 1.0, 'moderate': 1.5, 'high': 2.0}
|
||||
quality_factors = {
|
||||
'excellent': 1.15,
|
||||
|
|
@ -518,12 +519,15 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT duration_min, hr_avg, rpe
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '7 days'
|
||||
""", (profile_id,))
|
||||
AND date >= CURRENT_DATE - (%s::int * INTERVAL '1 day')
|
||||
""",
|
||||
(profile_id, days),
|
||||
)
|
||||
|
||||
activities = cur.fetchall()
|
||||
|
||||
|
|
@ -560,7 +564,12 @@ def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[int]:
|
|||
load = float(duration) * intensity_factors[intensity] * quality_factors.get(quality, 1.0)
|
||||
total_load += load
|
||||
|
||||
return int(total_load)
|
||||
return float(total_load)
|
||||
|
||||
|
||||
def calculate_proxy_internal_load_7d(profile_id: str) -> Optional[float]:
|
||||
"""Letzte 7 Tage — Kompatibilität mit Platzhaltern / älteren Aufrufern."""
|
||||
return calculate_proxy_internal_load_window(profile_id, 7)
|
||||
|
||||
|
||||
def calculate_monotony_score(profile_id: str) -> Optional[float]:
|
||||
|
|
@ -1353,3 +1362,176 @@ def build_training_type_distribution_chart_payload(profile_id: str, days: int) -
|
|||
"uncategorized_sessions": dist_data["uncategorized_sessions"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_training_volume_two_week_delta(profile_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Trainingsminuten: letzte 7 Kalendertage vs. die 7 Tage davor (Fortschritt Volumen).
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT
|
||||
COALESCE(SUM(duration_min) FILTER (WHERE date >= CURRENT_DATE - INTERVAL '7 days'), 0)::bigint AS last7,
|
||||
COALESCE(SUM(duration_min) FILTER (
|
||||
WHERE date < CURRENT_DATE - INTERVAL '7 days'
|
||||
AND date >= CURRENT_DATE - INTERVAL '14 days'), 0)::bigint AS prev7
|
||||
FROM activity_log
|
||||
WHERE profile_id = %s
|
||||
AND date >= CURRENT_DATE - INTERVAL '14 days'
|
||||
""",
|
||||
(profile_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
||||
last7 = int(row["last7"] or 0)
|
||||
prev7 = int(row["prev7"] or 0)
|
||||
if last7 == 0 and prev7 == 0:
|
||||
return {"last7_min": 0, "prior7_min": 0, "delta_pct": None, "has_data": False}
|
||||
delta_pct: Optional[float] = None
|
||||
if prev7 > 0:
|
||||
delta_pct = round((last7 - prev7) / float(prev7) * 100.0, 1)
|
||||
return {
|
||||
"last7_min": last7,
|
||||
"prior7_min": prev7,
|
||||
"delta_pct": delta_pct,
|
||||
"has_data": True,
|
||||
}
|
||||
|
||||
|
||||
def build_quality_sessions_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""Qualitäts-Sessions vs. regulär — gleiche Logik wie GET /api/charts/quality-sessions."""
|
||||
if days < 7:
|
||||
days = 7
|
||||
if days > 90:
|
||||
days = 90
|
||||
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as total
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
total_sessions = row["total"] if row else 0
|
||||
|
||||
if total_sessions == 0:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Aktivitätsdaten",
|
||||
},
|
||||
}
|
||||
|
||||
q = float(quality_pct or 0)
|
||||
quality_count = int(round(q / 100.0 * total_sessions))
|
||||
quality_count = max(0, min(quality_count, total_sessions))
|
||||
regular_count = total_sessions - quality_count
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Anzahl",
|
||||
"data": [quality_count, regular_count],
|
||||
"backgroundColor": ["#1D9E75", "#888"],
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(total_sessions, days, "general"),
|
||||
"data_points": total_sessions,
|
||||
"quality_pct": round(q, 1),
|
||||
"quality_count": quality_count,
|
||||
"regular_count": regular_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_load_monitoring_chart_payload(profile_id: str, days: int) -> Dict[str, Any]:
|
||||
"""Tages-Load-Zeitreihe + ACWR — gleiche Logik wie GET /api/charts/load-monitoring."""
|
||||
if days < 14:
|
||||
days = 14
|
||||
if days > 90:
|
||||
days = 90
|
||||
|
||||
acute_load = calculate_proxy_internal_load_window(profile_id, 7)
|
||||
chronic_load = calculate_proxy_internal_load_window(profile_id, 28)
|
||||
|
||||
acwr = (
|
||||
(acute_load / chronic_load) if acute_load is not None and chronic_load and chronic_load > 0 else 0.0
|
||||
)
|
||||
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
date,
|
||||
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {"labels": [], "datasets": []},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Load-Daten",
|
||||
},
|
||||
}
|
||||
|
||||
labels = [row["date"].isoformat() for row in rows]
|
||||
values = [safe_float(row["daily_load"]) for row in rows]
|
||||
|
||||
al = float(acute_load) if acute_load is not None else 0.0
|
||||
cl = float(chronic_load) if chronic_load is not None else 0.0
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Tages-Load",
|
||||
"data": values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
"metadata": serialize_dates(
|
||||
{
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"acute_load_7d": round(al, 1),
|
||||
"chronic_load_28d": round(cl, 1),
|
||||
"acwr": round(acwr, 2),
|
||||
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,87 @@ def _vo2_status(trend: Optional[float]) -> str:
|
|||
return "bad"
|
||||
|
||||
|
||||
def _vol_delta_status(delta_pct: Optional[float], prior7: int, last7: int) -> str:
|
||||
if delta_pct is None:
|
||||
if last7 > 0 and prior7 == 0:
|
||||
return "good"
|
||||
return "warn"
|
||||
if delta_pct >= 5:
|
||||
return "good"
|
||||
if delta_pct >= -10:
|
||||
return "warn"
|
||||
return "bad"
|
||||
|
||||
|
||||
def build_fitness_progress_insights(
|
||||
vol_delta: Dict[str, Any],
|
||||
load_meta: Dict[str, Any],
|
||||
quality_pct: Optional[int],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Kurz-Aussagen für die UI (Layer 2b), keine zweite Datenquelle.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
if vol_delta.get("has_data"):
|
||||
last7 = int(vol_delta.get("last7_min") or 0)
|
||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
||||
d = vol_delta.get("delta_pct")
|
||||
if d is not None:
|
||||
sign = "+" if d > 0 else ""
|
||||
body = (
|
||||
f"Trainingsminuten letzte 7 Tage ({last7} min) vs. Vorwoche ({prev7} min): "
|
||||
f"{sign}{d} %."
|
||||
)
|
||||
elif last7 > 0 and prev7 == 0:
|
||||
body = f"Mehr Volumen als in der Vorwoche: zuletzt {last7} min (Vorwoche 0 min)."
|
||||
else:
|
||||
body = "Zu wenig Daten für einen Vorwochen-Vergleich."
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_vol_trend",
|
||||
"tone": _vol_delta_status(
|
||||
float(d) if d is not None else None, prev7, last7
|
||||
),
|
||||
"title": "Volumen-Trend",
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
|
||||
acwr = load_meta.get("acwr")
|
||||
st = load_meta.get("acwr_status")
|
||||
if acwr is not None and isinstance(load_meta, dict) and load_meta.get("data_points", 0) > 0:
|
||||
if st == "optimal":
|
||||
tone = "good"
|
||||
hint = "Akute zu chronischer Last (ACWR) liegt im oft empfohlenen Bereich (ca. 0,8–1,3)."
|
||||
else:
|
||||
tone = "warn"
|
||||
hint = (
|
||||
"ACWR außerhalb des häufig genannten Zielkorridors — bei anhaltender Belastung "
|
||||
"Erholung oder Volumen prüfen (Proxy-Modell)."
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_acwr",
|
||||
"tone": tone,
|
||||
"title": "Belastungsverhältnis (ACWR)",
|
||||
"body": f"Verhältnis akut (7 Tage) zu chronisch (28 Tage): {float(acwr):.2f}. {hint}",
|
||||
}
|
||||
)
|
||||
|
||||
if quality_pct is not None:
|
||||
tone = "good" if quality_pct >= 60 else "warn" if quality_pct >= 40 else "bad"
|
||||
out.append(
|
||||
{
|
||||
"key": "ins_quality",
|
||||
"tone": tone,
|
||||
"title": "Session-Qualität",
|
||||
"body": f"{quality_pct} % der Sessions sind als «gut» oder besser eingestuft — Grundlage für progressive Belastung.",
|
||||
}
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def build_fitness_dashboard_kpi_tiles(
|
||||
summary: Dict[str, Any],
|
||||
minutes_7d: Optional[int],
|
||||
|
|
@ -65,6 +146,7 @@ def build_fitness_dashboard_kpi_tiles(
|
|||
activity_score: Optional[int],
|
||||
vo2_trend: Optional[float],
|
||||
top_focus: Optional[Dict[str, Any]],
|
||||
vol_delta: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
spw = summary.get("sessions_per_week")
|
||||
try:
|
||||
|
|
@ -78,7 +160,40 @@ def build_fitness_dashboard_kpi_tiles(
|
|||
s_status = _score_status(activity_score)
|
||||
v_status = _vo2_status(vo2_trend)
|
||||
|
||||
tiles: List[Dict[str, Any]] = [
|
||||
tiles: List[Dict[str, Any]] = []
|
||||
|
||||
if vol_delta and vol_delta.get("has_data"):
|
||||
d = vol_delta.get("delta_pct")
|
||||
last7 = int(vol_delta.get("last7_min") or 0)
|
||||
prev7 = int(vol_delta.get("prior7_min") or 0)
|
||||
if d is not None:
|
||||
sign = "+" if float(d) > 0 else ""
|
||||
v_s = f"{sign}{d:.1f} %".replace(".", ",")
|
||||
sub = f"{last7} min vs. {prev7} min (7-Tage-Fenster)"
|
||||
elif last7 > 0 and prev7 == 0:
|
||||
v_s = "neu"
|
||||
sub = f"{last7} min letzte Woche"
|
||||
else:
|
||||
v_s = "—"
|
||||
sub = "Vergleich Vorwoche"
|
||||
vd_st = _vol_delta_status(float(d) if d is not None else None, prev7, last7)
|
||||
tiles.append(
|
||||
{
|
||||
"key": "volume_vs_prior_week",
|
||||
"category": "Volumen vs. Vorwoche",
|
||||
"icon": "📈",
|
||||
"value": v_s,
|
||||
"sublabel": sub,
|
||||
"status": vd_st,
|
||||
"verdict": _verdict(vd_st),
|
||||
"hoverTop": "Fortschritt Trainingsminuten",
|
||||
"hoverBody": "Letzte 7 Kalendertage vs. die 7 Tage davor (activity_log).",
|
||||
"keys": ["training_minutes_week", "activity_summary"],
|
||||
}
|
||||
)
|
||||
|
||||
tiles.extend(
|
||||
[
|
||||
{
|
||||
"key": "minutes_week",
|
||||
"category": "Minuten (7 Tage)",
|
||||
|
|
@ -140,6 +255,7 @@ def build_fitness_dashboard_kpi_tiles(
|
|||
"keys": ["vo2max_trend_28d"],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if top_focus:
|
||||
prog = top_focus.get("progress")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from typing import Any, Dict, Optional
|
|||
|
||||
from db import get_db, get_cursor
|
||||
from data_layer.activity_metrics import (
|
||||
build_load_monitoring_chart_payload,
|
||||
build_quality_sessions_chart_payload,
|
||||
build_training_type_distribution_chart_payload,
|
||||
build_training_volume_chart_payload,
|
||||
calculate_activity_score,
|
||||
|
|
@ -17,8 +19,12 @@ from data_layer.activity_metrics import (
|
|||
calculate_quality_sessions_pct,
|
||||
calculate_vo2max_trend_28d,
|
||||
get_activity_summary_data,
|
||||
get_training_volume_two_week_delta,
|
||||
)
|
||||
from data_layer.fitness_interpretation import (
|
||||
build_fitness_dashboard_kpi_tiles,
|
||||
build_fitness_progress_insights,
|
||||
)
|
||||
from data_layer.fitness_interpretation import build_fitness_dashboard_kpi_tiles
|
||||
from data_layer.scores import get_top_focus_area
|
||||
|
||||
|
||||
|
|
@ -66,6 +72,8 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"message": "Noch keine Aktivitätsdaten",
|
||||
"kpi_tiles": [],
|
||||
"summary": {},
|
||||
"progress_insights": [],
|
||||
"volume_delta": {},
|
||||
"charts": {},
|
||||
"meta": {"layer_1": "activity_metrics", "layer_2b": "fitness_viz"},
|
||||
}
|
||||
|
|
@ -77,9 +85,12 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
|
||||
weeks_vol = max(4, min(52, (min(eff_days, 365) + 6) // 7))
|
||||
dist_days = min(90, max(7, min(eff_days, 365)))
|
||||
load_days = min(90, max(14, min(eff_days, 365)))
|
||||
|
||||
volume_chart = build_training_volume_chart_payload(profile_id, weeks_vol)
|
||||
type_chart = build_training_type_distribution_chart_payload(profile_id, dist_days)
|
||||
quality_chart = build_quality_sessions_chart_payload(profile_id, dist_days)
|
||||
load_chart = build_load_monitoring_chart_payload(profile_id, load_days)
|
||||
|
||||
quality_days = dist_days
|
||||
quality_pct = calculate_quality_sessions_pct(profile_id, quality_days)
|
||||
|
|
@ -87,6 +98,7 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
activity_score = calculate_activity_score(profile_id)
|
||||
vo2_trend = calculate_vo2max_trend_28d(profile_id)
|
||||
top_focus = get_top_focus_area(profile_id)
|
||||
vol_delta = get_training_volume_two_week_delta(profile_id)
|
||||
|
||||
kpi_tiles = build_fitness_dashboard_kpi_tiles(
|
||||
summary,
|
||||
|
|
@ -96,8 +108,14 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
activity_score,
|
||||
vo2_trend,
|
||||
top_focus,
|
||||
vol_delta,
|
||||
)
|
||||
|
||||
load_meta = load_chart.get("metadata") or {}
|
||||
if not isinstance(load_meta, dict):
|
||||
load_meta = {}
|
||||
progress_insights = build_fitness_progress_insights(vol_delta, load_meta, quality_pct)
|
||||
|
||||
conf = summary.get("confidence") or "medium"
|
||||
if summary.get("activity_count", 0) == 0:
|
||||
conf = "insufficient"
|
||||
|
|
@ -113,10 +131,15 @@ def get_fitness_dashboard_viz_bundle(profile_id: str, days: int) -> Dict[str, An
|
|||
"summary": summary,
|
||||
"kpi_tiles": kpi_tiles,
|
||||
"interpretation_tiles": [],
|
||||
"progress_insights": progress_insights,
|
||||
"volume_delta": vol_delta,
|
||||
"charts": {
|
||||
"training_volume": volume_chart,
|
||||
"training_type_distribution": type_chart,
|
||||
"quality_sessions": quality_chart,
|
||||
"load_monitoring": load_chart,
|
||||
},
|
||||
"load_chart_days_used": load_days,
|
||||
"meta": {
|
||||
"layer_1": "activity_metrics",
|
||||
"layer_2b": "fitness_viz",
|
||||
|
|
|
|||
|
|
@ -46,13 +46,13 @@ from data_layer.nutrition_metrics import (
|
|||
from data_layer.activity_metrics import (
|
||||
get_activity_summary_data,
|
||||
calculate_training_minutes_week,
|
||||
calculate_quality_sessions_pct,
|
||||
calculate_proxy_internal_load_7d,
|
||||
calculate_monotony_score,
|
||||
calculate_strain_score,
|
||||
calculate_ability_balance,
|
||||
build_training_volume_chart_payload,
|
||||
build_training_type_distribution_chart_payload,
|
||||
build_quality_sessions_chart_payload,
|
||||
build_load_monitoring_chart_payload,
|
||||
)
|
||||
from data_layer.recovery_metrics import (
|
||||
get_sleep_duration_data,
|
||||
|
|
@ -1115,63 +1115,7 @@ def get_quality_sessions_chart(
|
|||
Chart.js bar chart with quality metrics
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# Calculate quality session percentage
|
||||
quality_pct = calculate_quality_sessions_pct(profile_id, days)
|
||||
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT COUNT(*) as total
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
total_sessions = row['total'] if row else 0
|
||||
|
||||
if total_sessions == 0:
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Aktivitätsdaten"
|
||||
}
|
||||
}
|
||||
|
||||
quality_count = int(quality_pct / 100 * total_sessions)
|
||||
regular_count = total_sessions - quality_count
|
||||
|
||||
return {
|
||||
"chart_type": "bar",
|
||||
"data": {
|
||||
"labels": ["Qualitäts-Sessions", "Reguläre Sessions"],
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Anzahl",
|
||||
"data": [quality_count, regular_count],
|
||||
"backgroundColor": ["#1D9E75", "#888"],
|
||||
"borderColor": "#085041",
|
||||
"borderWidth": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": calculate_confidence(total_sessions, days, "general"),
|
||||
"data_points": total_sessions,
|
||||
"quality_pct": round(quality_pct, 1),
|
||||
"quality_count": quality_count,
|
||||
"regular_count": regular_count
|
||||
}
|
||||
}
|
||||
return build_quality_sessions_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/load-monitoring")
|
||||
|
|
@ -1192,74 +1136,7 @@ def get_load_monitoring_chart(
|
|||
Chart.js line chart with load metrics
|
||||
"""
|
||||
profile_id = session['profile_id']
|
||||
|
||||
# Calculate loads
|
||||
acute_load = calculate_proxy_internal_load_7d(profile_id)
|
||||
chronic_load = calculate_proxy_internal_load_7d(profile_id, days=28)
|
||||
|
||||
# ACWR (Acute:Chronic Workload Ratio)
|
||||
acwr = acute_load / chronic_load if chronic_load > 0 else 0
|
||||
|
||||
# Fetch daily loads for timeline
|
||||
from db import get_db, get_cursor
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
cur.execute(
|
||||
"""SELECT
|
||||
date,
|
||||
SUM(duration_min * COALESCE(rpe, 5)) as daily_load
|
||||
FROM activity_log
|
||||
WHERE profile_id=%s AND date >= %s
|
||||
GROUP BY date
|
||||
ORDER BY date""",
|
||||
(profile_id, cutoff)
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
|
||||
if not rows:
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": [],
|
||||
"datasets": []
|
||||
},
|
||||
"metadata": {
|
||||
"confidence": "insufficient",
|
||||
"data_points": 0,
|
||||
"message": "Keine Load-Daten"
|
||||
}
|
||||
}
|
||||
|
||||
labels = [row['date'].isoformat() for row in rows]
|
||||
values = [safe_float(row['daily_load']) for row in rows]
|
||||
|
||||
return {
|
||||
"chart_type": "line",
|
||||
"data": {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Tages-Load",
|
||||
"data": values,
|
||||
"borderColor": "#1D9E75",
|
||||
"backgroundColor": "rgba(29, 158, 117, 0.1)",
|
||||
"borderWidth": 2,
|
||||
"tension": 0.3,
|
||||
"fill": True
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": serialize_dates({
|
||||
"confidence": calculate_confidence(len(rows), days, "general"),
|
||||
"data_points": len(rows),
|
||||
"acute_load_7d": round(acute_load, 1),
|
||||
"chronic_load_28d": round(chronic_load, 1),
|
||||
"acwr": round(acwr, 2),
|
||||
"acwr_status": "optimal" if 0.8 <= acwr <= 1.3 else "suboptimal"
|
||||
})
|
||||
}
|
||||
return build_load_monitoring_chart_payload(profile_id, days)
|
||||
|
||||
|
||||
@router.get("/monotony-strain")
|
||||
|
|
|
|||
|
|
@ -42,13 +42,13 @@
|
|||
|
||||
## Erweiterungen (optional)
|
||||
|
||||
- Weitere Charts aus A3–A8 ins Bundle ziehen (weiterhin nur Payload-Referenz, keine Duplikat-Logik im Router).
|
||||
- Gitea-Issue anlegen/verknüpfen, falls formale Nachverfolgung gewünscht.
|
||||
- Weitere Charts aus A5–A8 ins Bundle (Monotonie, Fähigkeiten …), gleiches Muster: Builder in `activity_metrics`, Router nur delegieren.
|
||||
|
||||
---
|
||||
|
||||
## Abnahme-Checkliste
|
||||
|
||||
- [x] Bundle liefert `has_activity_entries`, `summary`, `kpi_tiles`, `charts.training_volume`, `charts.training_type_distribution`, `meta`.
|
||||
- [x] Keine clientseitige Neuberechnung der KPIs aus Rohlisten.
|
||||
- [x] `/api/charts/training-volume` und `/training-type-distribution` nutzen dieselben Builder wie das Bundle.
|
||||
- [x] Bundle liefert u. a. `has_activity_entries`, `summary`, `kpi_tiles`, `progress_insights`, `volume_delta`, `charts.training_volume`, `charts.training_type_distribution`, `charts.quality_sessions`, `charts.load_monitoring`, `load_chart_days_used`, `meta`.
|
||||
- [x] Verlauf `/history` → Fitness: **keine** zweiten Charts/KPIs aus `activities`-Liste (keine Redundanz zur Erfassungs-API).
|
||||
- [x] Chart-Endpunkte A3/A4 nutzen dieselben Builder wie das Bundle (`build_quality_sessions_chart_payload`, `build_load_monitoring_chart_payload`).
|
||||
- [x] `calculate_proxy_internal_load_window` ersetzt fehlerhaften `days=28`-Aufruf an der alten 7-Tage-Funktion (chronische Last).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
|
|
@ -9,9 +10,14 @@ import {
|
|||
CartesianGrid,
|
||||
PieChart,
|
||||
Pie,
|
||||
LineChart,
|
||||
Line,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { api } from '../utils/api'
|
||||
import KpiTilesOverview from './KpiTilesOverview'
|
||||
import { getStatusColor } from '../utils/interpret'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const PERIODS = [
|
||||
{ v: 7, label: '7 Tage' },
|
||||
|
|
@ -22,16 +28,13 @@ const PERIODS = [
|
|||
|
||||
/**
|
||||
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
|
||||
*
|
||||
* @param {number} [period] – gesteuert von außen (z. B. Verlauf `PeriodSelector`); mit `onPeriodChange` koppeln.
|
||||
* @param {(n: number) => void} [onPeriodChange]
|
||||
* @param {boolean} [hidePeriodSelector] – eigenes Zeitraum-Dropdown ausblenden (wenn die Seite oben schon einen Zeitraum wählt).
|
||||
*/
|
||||
export default function FitnessDashboardOverview({
|
||||
period: periodProp,
|
||||
onPeriodChange,
|
||||
hidePeriodSelector = false,
|
||||
}) {
|
||||
const nav = useNavigate()
|
||||
const [internalPeriod, setInternalPeriod] = useState(28)
|
||||
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
|
||||
const period = controlled ? periodProp : internalPeriod
|
||||
|
|
@ -82,16 +85,21 @@ export default function FitnessDashboardOverview({
|
|||
return (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Fitness-Übersicht</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Kennzahlen und
|
||||
Diagramme hier.
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 14 }}>
|
||||
Noch keine Aktivitätsdaten. Sobald du Trainings erfasst oder importierst, erscheinen Auswertungen hier.
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" onClick={() => nav('/activity')}>
|
||||
Zur Erfassung
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const vol = viz.charts?.training_volume
|
||||
const typ = viz.charts?.training_type_distribution
|
||||
const qual = viz.charts?.quality_sessions
|
||||
const loadCh = viz.charts?.load_monitoring
|
||||
|
||||
const volRows = (vol?.data?.labels || []).map((name, i) => ({
|
||||
name,
|
||||
min: vol?.data?.datasets?.[0]?.data?.[i] ?? 0,
|
||||
|
|
@ -105,15 +113,34 @@ export default function FitnessDashboardOverview({
|
|||
fill: pieColors[i] || '#888780',
|
||||
}))
|
||||
|
||||
const qualLabels = qual?.data?.labels || []
|
||||
const qualVals = qual?.data?.datasets?.[0]?.data || []
|
||||
const qualBg = qual?.data?.datasets?.[0]?.backgroundColor || []
|
||||
const qualBar = qualLabels.map((name, i) => ({
|
||||
name,
|
||||
n: qualVals[i] ?? 0,
|
||||
fill: qualBg[i] || '#1D9E75',
|
||||
}))
|
||||
|
||||
const loadLabels = loadCh?.data?.labels || []
|
||||
const loadVals = loadCh?.data?.datasets?.[0]?.data || []
|
||||
const loadRows = loadLabels.map((iso, i) => ({
|
||||
t: dayjs(iso).format('DD.MM.'),
|
||||
load: loadVals[i] ?? 0,
|
||||
}))
|
||||
const loadMeta = loadCh?.metadata || {}
|
||||
|
||||
const kpiTiles = (viz.kpi_tiles || []).map((t) => ({
|
||||
...t,
|
||||
sublabel:
|
||||
typeof t.sublabel === 'string' && t.sublabel.length > 42 ? `${t.sublabel.slice(0, 40)}…` : t.sublabel,
|
||||
}))
|
||||
|
||||
const insights = viz.progress_insights || []
|
||||
const eff = viz.effective_window_days
|
||||
const wUsed = viz.training_volume_weeks_used
|
||||
const dTyp = viz.training_type_dist_days_used
|
||||
const loadDays = viz.load_chart_days_used
|
||||
|
||||
const showPeriodDropdown = !hidePeriodSelector && !controlled
|
||||
|
||||
|
|
@ -143,9 +170,9 @@ export default function FitnessDashboardOverview({
|
|||
</div>
|
||||
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Kennzahlen und Charts nutzen dieselbe Berechnung wie die KI-Platzhalter (Aktivitäts-Data-Layer). Zusammenfassung
|
||||
ca. <strong>{eff}</strong> Tage · Volumen-Chart <strong>{wUsed}</strong> Wochen · Typ-Verteilung{' '}
|
||||
<strong>{dTyp}</strong> Tage
|
||||
Alles aus dem Aktivitäts-Data-Layer (Issue 53). Zusammenfassung ca. <strong>{eff}</strong> Tage · Volumen{' '}
|
||||
<strong>{wUsed}</strong> Wochen · Kategorien <strong>{dTyp}</strong> Tage · Load-Zeitreihe{' '}
|
||||
<strong>{loadDays ?? '—'}</strong> Tage
|
||||
{viz.last_updated ? (
|
||||
<>
|
||||
{' '}
|
||||
|
|
@ -157,10 +184,33 @@ export default function FitnessDashboardOverview({
|
|||
|
||||
<KpiTilesOverview tiles={kpiTiles} heading="Kennzahlen" />
|
||||
|
||||
{insights.length > 0 ? (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 8 }}>Einschätzungen</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{insights.map((ins) => (
|
||||
<div
|
||||
key={ins.key}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderLeft: `4px solid ${getStatusColor(['good', 'warn', 'bad'].includes(ins.tone) ? ins.tone : 'warn')}`,
|
||||
background: 'var(--surface2)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4 }}>{ins.title}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text2)', lineHeight: 1.45 }}>{ins.body}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))',
|
||||
gap: 16,
|
||||
marginTop: 8,
|
||||
}}
|
||||
|
|
@ -173,7 +223,15 @@ export default function FitnessDashboardOverview({
|
|||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={volRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} interval={0} angle={-35} textAnchor="end" height={48} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fontSize: 9, fill: 'var(--text3)' }}
|
||||
tickLine={false}
|
||||
interval={0}
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={48}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
|
|
@ -222,6 +280,68 @@ export default function FitnessDashboardOverview({
|
|||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine kategorisierten Sessions im Fenster.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Qualitäts-Sessions (Schätzung)
|
||||
</div>
|
||||
{qualBar.length >= 1 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={qualBar} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="n" radius={[3, 3, 0, 0]}>
|
||||
{qualBar.map((entry, i) => (
|
||||
<Cell key={`q-${i}`} fill={entry.fill} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Daten.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ gridColumn: '1 / -1', maxWidth: '100%' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 6 }}>
|
||||
Belastung (Proxy-Load · duration×RPE / Tag)
|
||||
</div>
|
||||
{loadRows.length >= 1 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={loadRows} margin={{ top: 4, right: 8, bottom: 0, left: -12 }}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" />
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: 'var(--text3)' }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 8,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
<Line type="monotone" dataKey="load" stroke="#1D9E75" strokeWidth={2} dot={false} name="Load" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginTop: 6, lineHeight: 1.4 }}>
|
||||
ACWR {loadMeta.acwr != null ? Number(loadMeta.acwr).toFixed(2) : '—'} (
|
||||
{loadMeta.acwr_status === 'optimal' ? 'oft als günstig beschrieben' : 'außerhalb 0,8–1,3'} · Proxy)
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Keine Load-Daten im Fenster.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { getBfCategory } from '../utils/calc'
|
|||
import { getStatusColor, getStatusBg } from '../utils/interpret'
|
||||
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
|
||||
import Markdown from '../utils/Markdown'
|
||||
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
|
||||
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
|
||||
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
|
||||
import RecoveryCharts from '../components/RecoveryCharts'
|
||||
|
|
@ -1098,37 +1097,10 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
|
|||
)
|
||||
}
|
||||
|
||||
// ── Activity Section — Layer 2b Fitness-Bundle wie Körper/Ernährung auf /history ─
|
||||
// ── Activity Section — nur Layer-2b-Bundle (+ KI-Insights), keine parallelen Client-Charts ─
|
||||
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
|
||||
const [period, setPeriod] = useState(30)
|
||||
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
|
||||
|
||||
// Issue #31: Backend already filters by global quality level - only filter by period here
|
||||
const actList = activities || []
|
||||
const filtA = actList.filter(d => period === 9999 || d.date >= cutoff)
|
||||
|
||||
const byDate={}
|
||||
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
|
||||
const cd=Object.entries(byDate).sort((a,b)=>a[0].localeCompare(b[0])).map(([date,kcal])=>({date:fmtDate(date),kcal:Math.round(kcal)}))
|
||||
|
||||
const totalKcal=Math.round(filtA.reduce((s,a)=>s+(a.kcal_active||0),0))
|
||||
const totalMin =Math.round(filtA.reduce((s,a)=>s+(a.duration_min||0),0))
|
||||
const hrData =filtA.filter(a=>a.hr_avg)
|
||||
const avgHr =hrData.length?Math.round(hrData.reduce((s,a)=>s+a.hr_avg,0)/hrData.length):null
|
||||
const types={}; filtA.forEach(a=>{ types[a.activity_type]=(types[a.activity_type]||0)+1 })
|
||||
const topTypes=Object.entries(types).sort((a,b)=>b[1]-a[1])
|
||||
|
||||
const daysWithAct=new Set(filtA.map(a=>a.date)).size
|
||||
const totalDays=Math.min(period,dayjs().diff(dayjs(filtA[filtA.length-1]?.date),'day')+1)
|
||||
const consistency=totalDays>0?Math.round(daysWithAct/totalDays*100):0
|
||||
const actRules=[{
|
||||
status:consistency>=70?'good':consistency>=40?'warn':'bad',
|
||||
icon:'📅', category:'Konsistenz',
|
||||
title:`${consistency}% aktive Tage (${daysWithAct}/${Math.min(period,30)} Tage)`,
|
||||
detail:consistency>=70?'Ausgezeichnete Regelmäßigkeit.':consistency>=40?'Ziel: 4–5 Einheiten/Woche.':'Mehr Regelmäßigkeit empfohlen.',
|
||||
value:consistency+'%'
|
||||
}]
|
||||
|
||||
const hasList = actList.length > 0
|
||||
|
||||
return (
|
||||
|
|
@ -1136,19 +1108,15 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
|
||||
<PeriodSelector value={period} onChange={setPeriod}/>
|
||||
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
|
||||
Fitness-Kennzahlen und Diagramme (Layer 2b) kommen aus dem Aktivitäts-Data-Layer — dieselbe Quelle wie die
|
||||
KI-Platzhalter. Zeitraum gilt auch für die Liste unten.
|
||||
Auswertung ausschließlich aus dem Fitness-Bundle (Data-Layer / Issue 53). Zeitraum-Buttons steuern dasselbe
|
||||
Fenster wie die API.
|
||||
</p>
|
||||
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
|
||||
|
||||
{!hasList ? (
|
||||
<EmptySection text="Noch keine Aktivitätsdaten im Verlauf." to="/activity" toLabel="Aktivität erfassen" />
|
||||
) : null}
|
||||
|
||||
{/* Issue #31: Show active global quality filter */}
|
||||
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
|
||||
<div style={{
|
||||
marginBottom:12, padding:'8px 12px', borderRadius:8,
|
||||
marginTop: 12,
|
||||
marginBottom: 12, padding:'8px 12px', borderRadius:8,
|
||||
background:'var(--surface2)', border:'1px solid var(--border)',
|
||||
fontSize:12, color:'var(--text2)', display:'flex', alignItems:'center', gap:8
|
||||
}}>
|
||||
|
|
@ -1166,53 +1134,9 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!hasList ? null : (
|
||||
<>
|
||||
<div style={{display:'flex',gap:6,marginBottom:12}}>
|
||||
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
|
||||
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
|
||||
avgHr?['Ø HF',avgHr+' bpm','#D85A30']:null].filter(Boolean).map(([l,v,c])=>(
|
||||
<div key={l} style={{flex:1,background:'var(--surface2)',borderRadius:8,padding:'8px 6px',textAlign:'center'}}>
|
||||
<div style={{fontSize:14,fontWeight:700,color:c}}>{v}</div>
|
||||
<div style={{fontSize:9,color:'var(--text3)'}}>{l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Aktive Kalorien / Tag</div>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={cd} margin={{top:4,right:8,bottom:0,left:-20}}>
|
||||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3"/>
|
||||
<XAxis dataKey="date" tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}
|
||||
interval={Math.max(0,Math.floor(cd.length/6)-1)}/>
|
||||
<YAxis tick={{fontSize:9,fill:'var(--text3)'}} tickLine={false}/>
|
||||
<Tooltip contentStyle={{background:'var(--surface)',border:'1px solid var(--border)',borderRadius:8,fontSize:11}}
|
||||
formatter={v=>[`${v} kcal`]}/>
|
||||
<Bar dataKey="kcal" fill="#EF9F2788" radius={[3,3,0,0]}/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingsarten</div>
|
||||
{topTypes.map(([type,count])=>(
|
||||
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
|
||||
<div style={{flex:1,fontSize:13}}>{type}</div>
|
||||
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
|
||||
<div style={{width:Math.max(4,Math.round(count/Math.max(1,filtA.length)*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="card" style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>Trainingstyp-Verteilung</div>
|
||||
<TrainingTypeDistribution days={period === 9999 ? 365 : period} />
|
||||
</div>
|
||||
<div style={{marginBottom:12}}>
|
||||
<div style={{fontSize:12,fontWeight:600,color:'var(--text3)',marginBottom:8}}>BEWERTUNG</div>
|
||||
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
|
||||
</div>
|
||||
{hasList ? (
|
||||
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user