feat: Phase 0c - recovery_metrics.py module complete
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / lint-backend (push) Successful in 1s
Build Test / build-frontend (push) Successful in 15s

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 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-03-28 19:13:59 +01:00
parent 6b2ad9fa1c
commit 432f7ba49f
3 changed files with 324 additions and 68 deletions

View File

@ -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',
]

View File

@ -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
}

View File

@ -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:
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)
if data['confidence'] == 'insufficient':
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"
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:
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)
if data['confidence'] == 'insufficient':
return "nicht verfügbar"
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 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: