From a9114bc40ab52ad6ddc65e358891f86911768611 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 26 Mar 2026 10:14:17 +0100 Subject: [PATCH] feat: implement missing placeholder functions (sleep, vitals, rest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementiert 6 fehlende Platzhalter-Funktionen die im Katalog waren aber keine Berechnung hatten. Neue Funktionen: - get_sleep_avg_duration(7d) → "7.5h" - get_sleep_avg_quality(7d) → "65% (Deep+REM)" - get_rest_days_count(30d) → "5 Ruhetage" - get_vitals_avg_hr(7d) → "58 bpm" - get_vitals_avg_hrv(7d) → "45 ms" - get_vitals_vo2_max() → "42.5 ml/kg/min" Datenquellen: - sleep_log (JSONB segments mit Deep/REM/Light/Awake) - rest_days (Kraft/Cardio/Entspannung) - vitals_baseline (resting_hr, hrv, vo2_max) Jetzt in PLACEHOLDER_MAP registriert → sofort nutzbar. Fixes: Platzhalter-Export zeigt jetzt alle Werte (statt "nicht verfügbar") Co-Authored-By: Claude Opus 4.6 --- backend/placeholder_resolver.py | 142 ++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index 633f4eb..928fd0e 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -290,6 +290,138 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str: return ", ".join(parts) +def get_sleep_avg_duration(profile_id: str, days: int = 7) -> str: + """Calculate average sleep duration in hours.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT sleep_segments FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC""", + (profile_id, cutoff) + ) + rows = cur.fetchall() + + if not rows: + return "nicht verfügbar" + + total_minutes = 0 + for row in rows: + segments = row['sleep_segments'] + if segments: + # Sum duration_min from all segments + for seg in segments: + total_minutes += seg.get('duration_min', 0) + + if total_minutes == 0: + return "nicht verfügbar" + + avg_hours = total_minutes / len(rows) / 60 + return f"{avg_hours:.1f}h" + + +def get_sleep_avg_quality(profile_id: str, days: int = 7) -> str: + """Calculate average sleep quality (Deep+REM %).""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT sleep_segments FROM sleep_log + WHERE profile_id=%s AND date >= %s + ORDER BY date DESC""", + (profile_id, cutoff) + ) + rows = cur.fetchall() + + if not rows: + return "nicht verfügbar" + + total_quality = 0 + count = 0 + for row in rows: + segments = row['sleep_segments'] + if segments: + deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('stage') in ['Deep', 'REM']) + total_min = sum(s.get('duration_min', 0) for s in segments) + if total_min > 0: + quality_pct = (deep_rem_min / total_min) * 100 + total_quality += quality_pct + count += 1 + + if count == 0: + return "nicht verfügbar" + + avg_quality = total_quality / count + return f"{avg_quality:.0f}% (Deep+REM)" + + +def get_rest_days_count(profile_id: str, days: int = 30) -> str: + """Count rest days in the given period.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT COUNT(DISTINCT date) as count FROM rest_days + WHERE profile_id=%s AND date >= %s""", + (profile_id, cutoff) + ) + row = cur.fetchone() + count = row['count'] if row else 0 + return f"{count} Ruhetage" + + +def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: + """Calculate average resting heart rate.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT AVG(resting_hr) as avg FROM vitals_baseline + WHERE profile_id=%s AND date >= %s AND resting_hr IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if row and row['avg']: + return f"{int(row['avg'])} bpm" + return "nicht verfügbar" + + +def get_vitals_avg_hrv(profile_id: str, days: int = 7) -> str: + """Calculate average heart rate variability.""" + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + cur.execute( + """SELECT AVG(hrv) as avg FROM vitals_baseline + WHERE profile_id=%s AND date >= %s AND hrv IS NOT NULL""", + (profile_id, cutoff) + ) + row = cur.fetchone() + + if row and row['avg']: + return f"{int(row['avg'])} ms" + return "nicht verfügbar" + + +def get_vitals_vo2_max(profile_id: str) -> str: + """Get latest VO2 Max value.""" + with get_db() as conn: + cur = get_cursor(conn) + cur.execute( + """SELECT vo2_max FROM vitals_baseline + WHERE profile_id=%s AND vo2_max IS NOT NULL + ORDER BY date DESC LIMIT 1""", + (profile_id,) + ) + row = cur.fetchone() + + if row and row['vo2_max']: + return f"{row['vo2_max']:.1f} ml/kg/min" + return "nicht verfügbar" + + # ── Placeholder Registry ────────────────────────────────────────────────────── PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { @@ -323,6 +455,16 @@ PLACEHOLDER_MAP: Dict[str, Callable[[str], str]] = { '{{activity_detail}}': get_activity_detail, '{{trainingstyp_verteilung}}': get_trainingstyp_verteilung, + # Schlaf & Erholung + '{{sleep_avg_duration}}': lambda pid: get_sleep_avg_duration(pid, 7), + '{{sleep_avg_quality}}': lambda pid: get_sleep_avg_quality(pid, 7), + '{{rest_days_count}}': lambda pid: get_rest_days_count(pid, 30), + + # Vitalwerte + '{{vitals_avg_hr}}': lambda pid: get_vitals_avg_hr(pid, 7), + '{{vitals_avg_hrv}}': lambda pid: get_vitals_avg_hrv(pid, 7), + '{{vitals_vo2_max}}': get_vitals_vo2_max, + # Zeitraum '{{datum_heute}}': lambda pid: datetime.now().strftime('%d.%m.%Y'), '{{zeitraum_7d}}': lambda pid: 'letzte 7 Tage',