From 432f7ba49fe4d04b792d33c50a35b0528a34c84b Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 28 Mar 2026 19:13:59 +0100 Subject: [PATCH] feat: Phase 0c - recovery_metrics.py module complete Data Layer: - get_sleep_duration_data() - avg duration with hours/minutes breakdown - get_sleep_quality_data() - Deep+REM percentage with phase breakdown - get_rest_days_data() - total count + breakdown by rest type Placeholder Layer: - get_sleep_avg_duration() - refactored to use data layer - get_sleep_avg_quality() - refactored to use data layer - get_rest_days_count() - refactored to use data layer All 3 recovery data functions + 3 placeholder refactors complete. Co-Authored-By: Claude Opus 4.6 --- backend/data_layer/__init__.py | 7 +- backend/data_layer/recovery_metrics.py | 287 +++++++++++++++++++++++++ backend/placeholder_resolver.py | 98 +++------ 3 files changed, 324 insertions(+), 68 deletions(-) create mode 100644 backend/data_layer/recovery_metrics.py diff --git a/backend/data_layer/__init__.py b/backend/data_layer/__init__.py index 3ae0dfa..ee89748 100644 --- a/backend/data_layer/__init__.py +++ b/backend/data_layer/__init__.py @@ -32,9 +32,9 @@ from .utils import * from .body_metrics import * from .nutrition_metrics import * from .activity_metrics import * +from .recovery_metrics import * # Future imports (will be added as modules are created): -# from .recovery_metrics import * # from .health_metrics import * # from .goals import * # from .correlations import * @@ -61,4 +61,9 @@ __all__ = [ 'get_activity_summary_data', 'get_activity_detail_data', 'get_training_type_distribution_data', + + # Recovery Metrics + 'get_sleep_duration_data', + 'get_sleep_quality_data', + 'get_rest_days_data', ] diff --git a/backend/data_layer/recovery_metrics.py b/backend/data_layer/recovery_metrics.py new file mode 100644 index 0000000..cf7ced0 --- /dev/null +++ b/backend/data_layer/recovery_metrics.py @@ -0,0 +1,287 @@ +""" +Recovery Metrics Data Layer + +Provides structured data for recovery tracking and analysis. + +Functions: + - get_sleep_duration_data(): Average sleep duration + - get_sleep_quality_data(): Sleep quality score (Deep+REM %) + - get_rest_days_data(): Rest day count and types + +All functions return structured data (dict) without formatting. +Use placeholder_resolver.py for formatted strings for AI. + +Phase 0c: Multi-Layer Architecture +Version: 1.0 +""" + +from typing import Dict, List, Optional +from datetime import datetime, timedelta, date +from db import get_db, get_cursor, r2d +from data_layer.utils import calculate_confidence, safe_float, safe_int + + +def get_sleep_duration_data( + profile_id: str, + days: int = 7 +) -> Dict: + """ + Calculate average sleep duration. + + Args: + profile_id: User profile ID + days: Analysis window (default 7) + + Returns: + { + "avg_duration_hours": float, + "avg_duration_minutes": int, + "total_nights": int, + "nights_with_data": int, + "confidence": str, + "days_analyzed": int + } + + Migration from Phase 0b: + OLD: get_sleep_avg_duration(pid, days) formatted string + NEW: Structured data with hours and minutes + """ + 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 { + "avg_duration_hours": 0.0, + "avg_duration_minutes": 0, + "total_nights": 0, + "nights_with_data": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + total_minutes = 0 + nights_with_data = 0 + + for row in rows: + segments = row['sleep_segments'] + if segments: + night_minutes = sum(seg.get('duration_min', 0) for seg in segments) + if night_minutes > 0: + total_minutes += night_minutes + nights_with_data += 1 + + if nights_with_data == 0: + return { + "avg_duration_hours": 0.0, + "avg_duration_minutes": 0, + "total_nights": len(rows), + "nights_with_data": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + avg_minutes = int(total_minutes / nights_with_data) + avg_hours = avg_minutes / 60 + + confidence = calculate_confidence(nights_with_data, days, "general") + + return { + "avg_duration_hours": round(avg_hours, 1), + "avg_duration_minutes": avg_minutes, + "total_nights": len(rows), + "nights_with_data": nights_with_data, + "confidence": confidence, + "days_analyzed": days + } + + +def get_sleep_quality_data( + profile_id: str, + days: int = 7 +) -> Dict: + """ + Calculate sleep quality score (Deep+REM percentage). + + Args: + profile_id: User profile ID + days: Analysis window (default 7) + + Returns: + { + "quality_score": float, # 0-100, Deep+REM percentage + "avg_deep_rem_minutes": int, + "avg_total_minutes": int, + "avg_light_minutes": int, + "avg_awake_minutes": int, + "nights_analyzed": int, + "confidence": str, + "days_analyzed": int + } + + Migration from Phase 0b: + OLD: get_sleep_avg_quality(pid, days) formatted string + NEW: Complete sleep phase breakdown + """ + 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 { + "quality_score": 0.0, + "avg_deep_rem_minutes": 0, + "avg_total_minutes": 0, + "avg_light_minutes": 0, + "avg_awake_minutes": 0, + "nights_analyzed": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + total_quality = 0 + total_deep_rem = 0 + total_light = 0 + total_awake = 0 + total_all = 0 + count = 0 + + for row in rows: + segments = row['sleep_segments'] + if segments: + # Note: segments use 'phase' key, stored lowercase (deep, rem, light, awake) + deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') in ['deep', 'rem']) + light_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'light') + awake_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') == 'awake') + 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 + total_deep_rem += deep_rem_min + total_light += light_min + total_awake += awake_min + total_all += total_min + count += 1 + + if count == 0: + return { + "quality_score": 0.0, + "avg_deep_rem_minutes": 0, + "avg_total_minutes": 0, + "avg_light_minutes": 0, + "avg_awake_minutes": 0, + "nights_analyzed": 0, + "confidence": "insufficient", + "days_analyzed": days + } + + avg_quality = total_quality / count + avg_deep_rem = int(total_deep_rem / count) + avg_total = int(total_all / count) + avg_light = int(total_light / count) + avg_awake = int(total_awake / count) + + confidence = calculate_confidence(count, days, "general") + + return { + "quality_score": round(avg_quality, 1), + "avg_deep_rem_minutes": avg_deep_rem, + "avg_total_minutes": avg_total, + "avg_light_minutes": avg_light, + "avg_awake_minutes": avg_awake, + "nights_analyzed": count, + "confidence": confidence, + "days_analyzed": days + } + + +def get_rest_days_data( + profile_id: str, + days: int = 30 +) -> Dict: + """ + Get rest days count and breakdown by type. + + Args: + profile_id: User profile ID + days: Analysis window (default 30) + + Returns: + { + "total_rest_days": int, + "rest_types": { + "strength": int, + "cardio": int, + "relaxation": int + }, + "rest_frequency": float, # days per week + "confidence": str, + "days_analyzed": int + } + + Migration from Phase 0b: + OLD: get_rest_days_count(pid, days) formatted string + NEW: Complete breakdown by rest type + """ + with get_db() as conn: + cur = get_cursor(conn) + cutoff = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + + # Get total distinct rest days + cur.execute( + """SELECT COUNT(DISTINCT date) as count FROM rest_days + WHERE profile_id=%s AND date >= %s""", + (profile_id, cutoff) + ) + total_row = cur.fetchone() + total_count = total_row['count'] if total_row else 0 + + # Get breakdown by rest type + cur.execute( + """SELECT rest_type, COUNT(*) as count FROM rest_days + WHERE profile_id=%s AND date >= %s + GROUP BY rest_type""", + (profile_id, cutoff) + ) + type_rows = cur.fetchall() + + rest_types = { + "strength": 0, + "cardio": 0, + "relaxation": 0 + } + + for row in type_rows: + rest_type = row['rest_type'] + if rest_type in rest_types: + rest_types[rest_type] = row['count'] + + # Calculate frequency (rest days per week) + rest_frequency = (total_count / days * 7) if days > 0 else 0.0 + + confidence = calculate_confidence(total_count, days, "general") + + return { + "total_rest_days": total_count, + "rest_types": rest_types, + "rest_frequency": round(rest_frequency, 1), + "confidence": confidence, + "days_analyzed": days + } diff --git a/backend/placeholder_resolver.py b/backend/placeholder_resolver.py index ebfc717..318f3d2 100644 --- a/backend/placeholder_resolver.py +++ b/backend/placeholder_resolver.py @@ -28,6 +28,11 @@ from data_layer.activity_metrics import ( get_activity_detail_data, get_training_type_distribution_data ) +from data_layer.recovery_metrics import ( + get_sleep_duration_data, + get_sleep_quality_data, + get_rest_days_data +) # ── Helper Functions ────────────────────────────────────────────────────────── @@ -305,85 +310,44 @@ def get_trainingstyp_verteilung(profile_id: str, days: int = 14) -> str: 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() + """ + Calculate average sleep duration in hours. - if not rows: - return "nicht verfügbar" + Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_duration_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_sleep_duration_data(profile_id, days) - 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 data['confidence'] == 'insufficient': + return "nicht verfügbar" - if total_minutes == 0: - return "nicht verfügbar" - - avg_hours = total_minutes / len(rows) / 60 - return f"{avg_hours:.1f}h" + return f"{data['avg_duration_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() + """ + Calculate average sleep quality (Deep+REM %). - if not rows: - return "nicht verfügbar" + Phase 0c: Refactored to use data_layer.recovery_metrics.get_sleep_quality_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_sleep_quality_data(profile_id, days) - total_quality = 0 - count = 0 - for row in rows: - segments = row['sleep_segments'] - if segments: - # Note: segments use 'phase' key (not 'stage'), stored lowercase (deep, rem, light, awake) - deep_rem_min = sum(s.get('duration_min', 0) for s in segments if s.get('phase') 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 data['confidence'] == 'insufficient': + return "nicht verfügbar" - if count == 0: - return "nicht verfügbar" - - avg_quality = total_quality / count - return f"{avg_quality:.0f}% (Deep+REM)" + return f"{data['quality_score']:.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" + """ + Count rest days in the given period. + + Phase 0c: Refactored to use data_layer.recovery_metrics.get_rest_days_data() + This function now only FORMATS the data for AI consumption. + """ + data = get_rest_days_data(profile_id, days) + return f"{data['total_rest_days']} Ruhetage" def get_vitals_avg_hr(profile_id: str, days: int = 7) -> str: