From 7d62434da2bc4b75b3df5cc47cdef8ca06e338f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Vit=C3=B3ria=20Silva?= Date: Sun, 7 Dec 2025 15:21:25 +0000 Subject: [PATCH] Add sleep scoring system and refactor health sleep fields Introduces a comprehensive sleep scoring module with detailed calculation logic and integration into health sleep creation and editing endpoints. Refactors HealthSleep model and schema to use integer types for heart rate, SpO2, respiration, and sleep stress fields. Updates related tests and documentation to reflect the new scoring system and data type changes. --- backend/app/garmin/health_utils.py | 2 +- backend/app/health_sleep/crud.py | 2 +- backend/app/health_sleep/models.py | 16 +- backend/app/health_sleep/router.py | 7 + backend/app/health_sleep/schema.py | 12 +- backend/app/health_sleep/sleep_scoring.py | 547 ++++++++++++++++ backend/app/health_steps/crud.py | 2 +- backend/app/health_weight/crud.py | 2 +- backend/tests/health_sleep/test_models.py | 10 +- backend/tests/health_sleep/test_router.py | 75 ++- backend/tests/health_sleep/test_schema.py | 14 +- .../tests/health_sleep/test_sleep_scoring.py | 585 ++++++++++++++++++ docs/getting-started/sleep-scoring.md | 288 +++++++++ .../HealthSleepAddEditModalComponent.vue | 142 ++--- .../healthSleepAddEditModalComponent.json | 4 +- .../healthSleepAddEditModalComponent.json | 5 +- .../healthSleepListTabsComponent.json | 4 +- frontend/app/src/views/HealthView.vue | 105 ++-- 18 files changed, 1640 insertions(+), 182 deletions(-) create mode 100644 backend/app/health_sleep/sleep_scoring.py create mode 100644 backend/tests/health_sleep/test_sleep_scoring.py create mode 100644 docs/getting-started/sleep-scoring.md diff --git a/backend/app/garmin/health_utils.py b/backend/app/garmin/health_utils.py index e7e062c5c..3e9cdac65 100644 --- a/backend/app/garmin/health_utils.py +++ b/backend/app/garmin/health_utils.py @@ -398,7 +398,7 @@ def fetch_and_process_sleep_by_dates( in health_sleep_schema.SleepScore._value2member_map_ else None ), - avg_sleep_stress=sleep_dto.get("avgSleepStress"), + avg_sleep_stress=int(sleep_dto.get("avgSleepStress")), sleep_stress_score=( health_sleep_schema.SleepScore(sleep_stress_score.get("qualifierKey")) if sleep_stress_score diff --git a/backend/app/health_sleep/crud.py b/backend/app/health_sleep/crud.py index 40f8f8034..89bb6edf4 100644 --- a/backend/app/health_sleep/crud.py +++ b/backend/app/health_sleep/crud.py @@ -157,7 +157,7 @@ def get_health_sleep_by_date( return ( db.query(health_sleep_models.HealthSleep) .filter( - health_sleep_models.HealthSleep.date == date, + health_sleep_models.HealthSleep.date == func.date(date), health_sleep_models.HealthSleep.user_id == user_id, ) .first() diff --git a/backend/app/health_sleep/models.py b/backend/app/health_sleep/models.py index 2a14986f3..26223d01b 100644 --- a/backend/app/health_sleep/models.py +++ b/backend/app/health_sleep/models.py @@ -35,16 +35,16 @@ class HealthSleep(Base): light_sleep_seconds (int): Duration of light sleep in seconds. rem_sleep_seconds (int): Duration of REM sleep in seconds. awake_sleep_seconds (int): Duration of awake time in seconds. - avg_heart_rate (Decimal): Average heart rate during sleep. + avg_heart_rate (int): Average heart rate during sleep. min_heart_rate (int): Minimum heart rate during sleep. max_heart_rate (int): Maximum heart rate during sleep. - avg_spo2 (Decimal): Average SpO2 oxygen saturation percentage. + avg_spo2 (int): Average SpO2 oxygen saturation percentage. lowest_spo2 (int): Lowest SpO2 reading during sleep. highest_spo2 (int): Highest SpO2 reading during sleep. - avg_respiration (Decimal): Average respiration rate. - lowest_respiration (Decimal): Lowest respiration rate. - highest_respiration (Decimal): Highest respiration rate. - avg_stress_level (Decimal): Average stress level during sleep. + avg_respiration (int): Average respiration rate. + lowest_respiration (int): Lowest respiration rate. + highest_respiration (int): Highest respiration rate. + avg_stress_level (int): Average stress level during sleep. awake_count (int): Number of times awakened during sleep. restless_moments_count (int): Count of restless moments. sleep_score_overall (int): Overall sleep score (0-100). @@ -60,7 +60,7 @@ class HealthSleep(Base): rem_percentage_score (str): REM sleep percentage score. deep_percentage_score (str): Deep sleep percentage score. light_percentage_score (str): Light sleep percentage score. - avg_sleep_stress (Decimal): Average sleep stress level. + avg_sleep_stress (int): Average sleep stress level. sleep_stress_score (str): Sleep stress score. user (relationship): SQLAlchemy relationship to the User model. """ @@ -262,7 +262,7 @@ class HealthSleep(Base): comment="Light sleep percentage score", ) avg_sleep_stress = Column( - DECIMAL(precision=10, scale=2), + Integer, nullable=True, comment="Average sleep stress level", ) diff --git a/backend/app/health_sleep/router.py b/backend/app/health_sleep/router.py index 6c0c540bd..553154739 100644 --- a/backend/app/health_sleep/router.py +++ b/backend/app/health_sleep/router.py @@ -5,6 +5,7 @@ from sqlalchemy.orm import Session import health_sleep.schema as health_sleep_schema import health_sleep.crud as health_sleep_crud +import health_sleep.sleep_scoring as sleep_scoring import auth.security as auth_security @@ -152,6 +153,9 @@ async def create_health_sleep( if not health_sleep.date: raise HTTPException(status_code=400, detail="Date field is required.") + # Calculate sleep scores before saving + sleep_scoring._calculate_and_set_sleep_scores(health_sleep) + # Convert date to string format for CRUD function date_str = health_sleep.date.isoformat() @@ -207,6 +211,9 @@ async def edit_health_sleep( HTTPException: May raise various HTTP exceptions if authorization fails, user is not found, or database operations fail. """ + # Recalculate sleep scores when editing + sleep_scoring._calculate_and_set_sleep_scores(health_sleep) + # Updates the health_sleep in the database and returns it return health_sleep_crud.edit_health_sleep(token_user_id, health_sleep, db) diff --git a/backend/app/health_sleep/schema.py b/backend/app/health_sleep/schema.py index a57b2ad6f..070f92930 100644 --- a/backend/app/health_sleep/schema.py +++ b/backend/app/health_sleep/schema.py @@ -176,7 +176,7 @@ class HealthSleep(BaseModel): rem_percentage_score: SleepScore | None = None deep_percentage_score: SleepScore | None = None light_percentage_score: SleepScore | None = None - avg_sleep_stress: Decimal | None = None + avg_sleep_stress: int | None = None sleep_stress_score: SleepScore | None = None model_config = ConfigDict( @@ -188,11 +188,10 @@ class HealthSleep(BaseModel): @field_validator("avg_heart_rate", "min_heart_rate", "max_heart_rate") @classmethod - def validate_heart_rate(cls, v: int | Decimal | None) -> int | Decimal | None: + def validate_heart_rate(cls, v: int | None) -> int | None: """Validate heart rate is within reasonable range (20-220 bpm).""" if v is not None: - value = float(v) if isinstance(v, Decimal) else v - if value < 20 or value > 220: + if v < 20 or v > 220: raise ValueError("Heart rate must be between 20 and 220 bpm") return v @@ -207,11 +206,10 @@ class HealthSleep(BaseModel): @field_validator("avg_spo2", "lowest_spo2", "highest_spo2") @classmethod - def validate_spo2(cls, v: int | Decimal | None) -> int | Decimal | None: + def validate_spo2(cls, v: int | None) -> int | None: """Validate SpO2 is within reasonable range (70-100%).""" if v is not None: - value = float(v) if isinstance(v, Decimal) else v - if value < 70 or value > 100: + if v < 70 or v > 100: raise ValueError("SpO2 must be between 70 and 100%") return v diff --git a/backend/app/health_sleep/sleep_scoring.py b/backend/app/health_sleep/sleep_scoring.py new file mode 100644 index 000000000..f4189182a --- /dev/null +++ b/backend/app/health_sleep/sleep_scoring.py @@ -0,0 +1,547 @@ +""" +Sleep scoring calculations. +""" + +import health_sleep.schema as health_sleep_schema + +from datetime import datetime + + +def _calculate_sleep_duration_hours( + total_sleep_seconds: int | None, +) -> float: + """ + Convert total sleep seconds to hours. + + Args: + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Sleep duration in hours as float. + """ + if total_sleep_seconds is None: + return 0.0 + return total_sleep_seconds / 3600.0 + + +def _calculate_stage_percentage( + stage_seconds: int | None, + total_sleep_seconds: int | None, +) -> float: + """ + Calculate percentage of a sleep stage relative to total sleep. + + Args: + stage_seconds: Duration of specific sleep stage in seconds. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Percentage of sleep stage (0-100). + """ + if stage_seconds is None or total_sleep_seconds is None or total_sleep_seconds == 0: + return 0.0 + return (stage_seconds / total_sleep_seconds) * 100.0 + + +def calculate_sleep_score_duration( + sleep_start_time: datetime | None, + sleep_end_time: datetime | None, + total_sleep_seconds: int | None, +) -> tuple[int, str]: + """ + Calculate sleep duration score based on total sleep time. + + Scoring criteria (based on adult sleep recommendations): + - 7-9 hours: EXCELLENT (90-100 points) + - 6-7 or 9-10 hours: GOOD (70-89 points) + - 5-6 or 10-11 hours: FAIR (50-69 points) + - < 5 or > 11 hours: POOR (0-49 points) + + Args: + sleep_start_time: Start time of sleep session. + sleep_end_time: End time of sleep session. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Tuple of (score points 0-100, score label). + """ + if total_sleep_seconds is None or total_sleep_seconds <= 0: + return (0, "POOR") + + hours = _calculate_sleep_duration_hours(total_sleep_seconds) + + # Optimal sleep range: 7-9 hours + if 7.0 <= hours <= 9.0: + # Scale within optimal range (7h = 90pts, 8h = 100pts, 9h + # = 90pts) + if hours <= 8.0: + score = int(90 + ((hours - 7.0) * 10)) + else: + score = int(100 - ((hours - 8.0) * 10)) + return (score, "EXCELLENT") + + # Good sleep range: 6-7 or 9-10 hours + if 6.0 <= hours < 7.0: + score = int(70 + ((hours - 6.0) * 20)) + return (score, "GOOD") + if 9.0 < hours <= 10.0: + score = int(89 - ((hours - 9.0) * 19)) + return (score, "GOOD") + + # Fair sleep range: 5-6 or 10-11 hours + if 5.0 <= hours < 6.0: + score = int(50 + ((hours - 5.0) * 20)) + return (score, "FAIR") + if 10.0 < hours <= 11.0: + score = int(69 - ((hours - 10.0) * 19)) + return (score, "FAIR") + + # Poor sleep: < 5 or > 11 hours + if hours < 5.0: + score = max(0, int(49 * (hours / 5.0))) + return (score, "POOR") + # > 11 hours + score = max(0, int(49 - ((hours - 11.0) * 5))) + return (score, "POOR") + + +def calculate_sleep_score_quality( + deep_sleep_seconds: int | None, + light_sleep_seconds: int | None, + rem_sleep_seconds: int | None, + awake_sleep_seconds: int | None, + total_sleep_seconds: int | None, +) -> tuple[int, str]: + """ + Calculate sleep quality score based on sleep stage composition. + + Scoring based on optimal sleep stage percentages: + - Deep: 13-23% optimal (peak at 18%) + - REM: 20-25% optimal (peak at 22.5%) + - Light: 45-55% optimal (peak at 50%) + - Awake: < 5% optimal + + Args: + deep_sleep_seconds: Duration of deep sleep in seconds. + light_sleep_seconds: Duration of light sleep in seconds. + rem_sleep_seconds: Duration of REM sleep in seconds. + awake_sleep_seconds: Duration of awake time in seconds. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Tuple of (score points 0-100, score label). + """ + if total_sleep_seconds is None or total_sleep_seconds <= 0: + return (0, "POOR") + + # Calculate percentages + deep_pct = _calculate_stage_percentage(deep_sleep_seconds, total_sleep_seconds) + rem_pct = _calculate_stage_percentage(rem_sleep_seconds, total_sleep_seconds) + light_pct = _calculate_stage_percentage(light_sleep_seconds, total_sleep_seconds) + awake_pct = _calculate_stage_percentage(awake_sleep_seconds, total_sleep_seconds) + + # Score each component (0-100) + deep_score = _score_sleep_stage(deep_pct, 13.0, 23.0, 18.0) + rem_score = _score_sleep_stage(rem_pct, 20.0, 25.0, 22.5) + light_score = _score_sleep_stage(light_pct, 45.0, 55.0, 50.0) + + # Awake penalty (lower is better) + if awake_pct <= 5.0: + awake_score = 100 - (awake_pct * 4) + else: + awake_score = max(0, 80 - ((awake_pct - 5.0) * 10)) + + # Weighted average: deep 25%, REM 30%, light 25%, awake 20% + total_score = int( + (deep_score * 0.25) + + (rem_score * 0.30) + + (light_score * 0.25) + + (awake_score * 0.20) + ) + + # Determine label + if total_score >= 90: + return (total_score, "EXCELLENT") + if total_score >= 70: + return (total_score, "GOOD") + if total_score >= 50: + return (total_score, "FAIR") + return (total_score, "POOR") + + +def _score_sleep_stage( + actual_pct: float, + min_optimal: float, + max_optimal: float, + peak_optimal: float, +) -> float: + """ + Score a sleep stage percentage against optimal range. + + Args: + actual_pct: Actual percentage of sleep stage. + min_optimal: Minimum optimal percentage. + max_optimal: Maximum optimal percentage. + peak_optimal: Peak optimal percentage. + + Returns: + Score from 0-100. + """ + if min_optimal <= actual_pct <= max_optimal: + # Within optimal range, calculate distance from peak + if actual_pct <= peak_optimal: + # Between min and peak + range_span = peak_optimal - min_optimal + if range_span > 0: + score = 80 + ((actual_pct - min_optimal) / range_span) * 20 + else: + score = 100 + else: + # Between peak and max + range_span = max_optimal - peak_optimal + if range_span > 0: + score = 100 - ((actual_pct - peak_optimal) / range_span) * 20 + else: + score = 100 + return min(100, max(80, score)) + + if actual_pct < min_optimal: + # Below optimal range + if min_optimal > 0: + score = 80 * (actual_pct / min_optimal) + else: + score = 0 + return max(0, score) + + # Above optimal range + over_pct = actual_pct - max_optimal + score = max(0, 80 - (over_pct * 4)) + return score + + +def calculate_awake_count_score( + awake_count: int | None, +) -> tuple[int, str]: + """ + Calculate score based on number of awakenings during sleep. + + Scoring criteria: + - 0-1 awakenings: EXCELLENT (90-100 points) + - 2-3 awakenings: GOOD (70-89 points) + - 4-5 awakenings: FAIR (50-69 points) + - 6+ awakenings: POOR (0-49 points) + + Args: + awake_count: Number of times awakened during sleep. + + Returns: + Tuple of (score points 0-100, score label). + """ + if awake_count is None: + return (50, "FAIR") + + if awake_count <= 1: + score = 100 - (awake_count * 5) + return (score, "EXCELLENT") + if awake_count <= 3: + score = 89 - ((awake_count - 2) * 10) + return (score, "GOOD") + if awake_count <= 5: + score = 69 - ((awake_count - 4) * 10) + return (score, "FAIR") + + # 6+ awakenings + score = max(0, 49 - ((awake_count - 6) * 5)) + return (score, "POOR") + + +def calculate_rem_percentage_score( + rem_sleep_seconds: int | None, + total_sleep_seconds: int | None, +) -> tuple[int, str]: + """ + Calculate score for REM sleep percentage. + + Optimal REM: 20-25% of total sleep (peak at 22.5%). + + Args: + rem_sleep_seconds: Duration of REM sleep in seconds. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Tuple of (score points 0-100, score label). + """ + if total_sleep_seconds is None or total_sleep_seconds <= 0: + return (0, "POOR") + + rem_pct = _calculate_stage_percentage(rem_sleep_seconds, total_sleep_seconds) + score = int(_score_sleep_stage(rem_pct, 20.0, 25.0, 22.5)) + + if score >= 90: + return (score, "EXCELLENT") + if score >= 70: + return (score, "GOOD") + if score >= 50: + return (score, "FAIR") + return (score, "POOR") + + +def calculate_deep_percentage_score( + deep_sleep_seconds: int | None, + total_sleep_seconds: int | None, +) -> tuple[int, str]: + """ + Calculate score for deep sleep percentage. + + Optimal deep sleep: 13-23% of total sleep (peak at 18%). + + Args: + deep_sleep_seconds: Duration of deep sleep in seconds. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Tuple of (score points 0-100, score label). + """ + if total_sleep_seconds is None or total_sleep_seconds <= 0: + return (0, "POOR") + + deep_pct = _calculate_stage_percentage(deep_sleep_seconds, total_sleep_seconds) + score = int(_score_sleep_stage(deep_pct, 13.0, 23.0, 18.0)) + + if score >= 90: + return (score, "EXCELLENT") + if score >= 70: + return (score, "GOOD") + if score >= 50: + return (score, "FAIR") + return (score, "POOR") + + +def calculate_light_percentage_score( + light_sleep_seconds: int | None, + total_sleep_seconds: int | None, +) -> tuple[int, str]: + """ + Calculate score for light sleep percentage. + + Optimal light sleep: 45-55% of total sleep (peak at 50%). + + Args: + light_sleep_seconds: Duration of light sleep in seconds. + total_sleep_seconds: Total sleep duration in seconds. + + Returns: + Tuple of (score points 0-100, score label). + """ + if total_sleep_seconds is None or total_sleep_seconds <= 0: + return (0, "POOR") + + light_pct = _calculate_stage_percentage(light_sleep_seconds, total_sleep_seconds) + score = int(_score_sleep_stage(light_pct, 45.0, 55.0, 50.0)) + + if score >= 90: + return (score, "EXCELLENT") + if score >= 70: + return (score, "GOOD") + if score >= 50: + return (score, "FAIR") + return (score, "POOR") + + +def calculate_sleep_stress_score( + avg_sleep_stress: int | None, + restless_moments_count: int | None, +) -> tuple[int, str]: + """ + Calculate sleep stress score. + + Based on average stress level and restless moments. + Garmin stress scale: 0-25 (rest), 26-50 (low), 51-75 (med), + 76-100 (high). + + Args: + avg_sleep_stress: Average stress level during sleep (0-100). + restless_moments_count: Count of restless moments. + + Returns: + Tuple of (score points 0-100, score label). + """ + if avg_sleep_stress is None: + return (50, "FAIR") + + stress_value = float(avg_sleep_stress) + + # Base score from stress level (inverted: lower stress = + # higher score) + if stress_value <= 25: # Rest state + base_score = 100 + elif stress_value <= 50: # Low stress + base_score = 90 - ((stress_value - 25) / 25 * 20) + elif stress_value <= 75: # Medium stress + base_score = 70 - ((stress_value - 50) / 25 * 20) + else: # High stress + base_score = 50 - ((stress_value - 75) / 25 * 50) + + # Penalty for restless moments + restless_penalty = 0 + if restless_moments_count is not None: + if restless_moments_count <= 5: + restless_penalty = restless_moments_count * 2 + else: + restless_penalty = 10 + ((restless_moments_count - 5) * 3) + + final_score = int(max(0, base_score - restless_penalty)) + + if final_score >= 90: + return (final_score, "EXCELLENT") + if final_score >= 70: + return (final_score, "GOOD") + if final_score >= 50: + return (final_score, "FAIR") + return (final_score, "POOR") + + +def calculate_sleep_score_overall( + sleep_start_time: datetime | None, + sleep_end_time: datetime | None, + total_sleep_seconds: int | None, + deep_sleep_seconds: int | None, + light_sleep_seconds: int | None, + rem_sleep_seconds: int | None, + awake_sleep_seconds: int | None, + awake_count: int | None, + restless_moments_count: int | None, + avg_sleep_stress: int | None, +) -> int: + """ + Calculate overall sleep score (0-100). + + Combines multiple sleep metrics with weighted contributions: + - Duration score: 30% + - Quality score: 40% + - Awake count: 10% + - Stress score: 20% + + Args: + sleep_start_time: Start time of sleep session. + sleep_end_time: End time of sleep session. + total_sleep_seconds: Total sleep duration in seconds. + deep_sleep_seconds: Duration of deep sleep in seconds. + light_sleep_seconds: Duration of light sleep in seconds. + rem_sleep_seconds: Duration of REM sleep in seconds. + awake_sleep_seconds: Duration of awake time in seconds. + awake_count: Number of times awakened during sleep. + restless_moments_count: Count of restless moments. + avg_sleep_stress: Average stress level during sleep. + + Returns: + Overall sleep score from 0-100. + """ + # Calculate component scores + duration_score, _ = calculate_sleep_score_duration( + sleep_start_time, sleep_end_time, total_sleep_seconds + ) + + quality_score, _ = calculate_sleep_score_quality( + deep_sleep_seconds, + light_sleep_seconds, + rem_sleep_seconds, + awake_sleep_seconds, + total_sleep_seconds, + ) + + awake_score, _ = calculate_awake_count_score(awake_count) + + stress_score, _ = calculate_sleep_stress_score( + avg_sleep_stress, restless_moments_count + ) + + # Weighted combination + overall_score = int( + (duration_score * 0.30) + + (quality_score * 0.40) + + (awake_score * 0.10) + + (stress_score * 0.20) + ) + + return max(0, min(100, overall_score)) + + +def _calculate_and_set_sleep_scores( + health_sleep: health_sleep_schema.HealthSleep, +) -> None: + """ + Calculate and set all sleep scores for a health sleep record. + + This function computes sleep quality scores and updates the + health_sleep object in place with calculated values. + + Args: + health_sleep: The health sleep record to update with scores. + + Returns: + None: Updates health_sleep object in place. + """ + # Calculate overall sleep score + health_sleep.sleep_score_overall = calculate_sleep_score_overall( + health_sleep.sleep_start_time_local, + health_sleep.sleep_end_time_local, + health_sleep.total_sleep_seconds, + health_sleep.deep_sleep_seconds, + health_sleep.light_sleep_seconds, + health_sleep.rem_sleep_seconds, + health_sleep.awake_sleep_seconds, + health_sleep.awake_count, + health_sleep.restless_moments_count, + health_sleep.avg_sleep_stress, + ) + + # Calculate duration score + _, duration_label = calculate_sleep_score_duration( + health_sleep.sleep_start_time_local, + health_sleep.sleep_end_time_local, + health_sleep.total_sleep_seconds, + ) + health_sleep.sleep_score_duration = health_sleep_schema.SleepScore(duration_label) + + # Calculate quality score + _, quality_label = calculate_sleep_score_quality( + health_sleep.deep_sleep_seconds, + health_sleep.light_sleep_seconds, + health_sleep.rem_sleep_seconds, + health_sleep.awake_sleep_seconds, + health_sleep.total_sleep_seconds, + ) + health_sleep.sleep_score_quality = health_sleep_schema.SleepScore(quality_label) + + # Calculate awake count score + _, awake_label = calculate_awake_count_score(health_sleep.awake_count) + health_sleep.awake_count_score = health_sleep_schema.SleepScore(awake_label) + + # Calculate REM percentage score + _, rem_label = calculate_rem_percentage_score( + health_sleep.rem_sleep_seconds, + health_sleep.total_sleep_seconds, + ) + health_sleep.rem_percentage_score = health_sleep_schema.SleepScore(rem_label) + + # Calculate deep percentage score + _, deep_label = calculate_deep_percentage_score( + health_sleep.deep_sleep_seconds, + health_sleep.total_sleep_seconds, + ) + health_sleep.deep_percentage_score = health_sleep_schema.SleepScore(deep_label) + + # Calculate light percentage score + _, light_label = calculate_light_percentage_score( + health_sleep.light_sleep_seconds, + health_sleep.total_sleep_seconds, + ) + health_sleep.light_percentage_score = health_sleep_schema.SleepScore(light_label) + + # Calculate sleep stress score + _, stress_label = calculate_sleep_stress_score( + health_sleep.avg_sleep_stress, + health_sleep.restless_moments_count, + ) + health_sleep.sleep_stress_score = health_sleep_schema.SleepScore(stress_label) diff --git a/backend/app/health_steps/crud.py b/backend/app/health_steps/crud.py index c5a0f5d30..d465ae455 100644 --- a/backend/app/health_steps/crud.py +++ b/backend/app/health_steps/crud.py @@ -159,7 +159,7 @@ def get_health_steps_by_date( return ( db.query(health_steps_models.HealthSteps) .filter( - health_steps_models.HealthSteps.date == date, + health_steps_models.HealthSteps.date == func.date(date), health_steps_models.HealthSteps.user_id == user_id, ) .first() diff --git a/backend/app/health_weight/crud.py b/backend/app/health_weight/crud.py index 92f2a3e1a..06a8a1723 100644 --- a/backend/app/health_weight/crud.py +++ b/backend/app/health_weight/crud.py @@ -197,7 +197,7 @@ def get_health_weight_by_date( return ( db.query(health_weight_models.HealthWeight) .filter( - health_weight_models.HealthWeight.date == date, + health_weight_models.HealthWeight.date == func.date(date), health_weight_models.HealthWeight.user_id == user_id, ) .first() diff --git a/backend/tests/health_sleep/test_models.py b/backend/tests/health_sleep/test_models.py index 6466e84ab..ed87cdc34 100644 --- a/backend/tests/health_sleep/test_models.py +++ b/backend/tests/health_sleep/test_models.py @@ -97,19 +97,17 @@ class TestHealthSleepModel: # Assert assert hasattr(health_sleep_models.HealthSleep, "user") - def test_health_sleep_model_decimal_precision(self): + def test_health_sleep_model_integer_fields(self): """ - Test HealthSleep model decimal fields precision. + Test HealthSleep model integer fields. """ # Arrange avg_heart_rate_column = health_sleep_models.HealthSleep.avg_heart_rate avg_spo2_column = health_sleep_models.HealthSleep.avg_spo2 # Assert - assert avg_heart_rate_column.type.precision == 10 - assert avg_heart_rate_column.type.scale == 2 - assert avg_spo2_column.type.precision == 10 - assert avg_spo2_column.type.scale == 2 + assert avg_heart_rate_column.type.python_type == int + assert avg_spo2_column.type.python_type == int def test_health_sleep_model_string_field_lengths(self): """ diff --git a/backend/tests/health_sleep/test_router.py b/backend/tests/health_sleep/test_router.py index 1accdd294..b4baa8dd0 100644 --- a/backend/tests/health_sleep/test_router.py +++ b/backend/tests/health_sleep/test_router.py @@ -37,10 +37,10 @@ class TestReadHealthSleepAll: mock_sleep1.light_sleep_seconds = 14400 mock_sleep1.rem_sleep_seconds = 7200 mock_sleep1.awake_sleep_seconds = 0 - mock_sleep1.avg_heart_rate = Decimal("55.5") + mock_sleep1.avg_heart_rate = 55 mock_sleep1.min_heart_rate = 45 mock_sleep1.max_heart_rate = 75 - mock_sleep1.avg_spo2 = Decimal("97.5") + mock_sleep1.avg_spo2 = 97 mock_sleep1.lowest_spo2 = 95 mock_sleep1.highest_spo2 = 99 mock_sleep1.avg_respiration = None @@ -297,6 +297,39 @@ class TestCreateHealthSleep: assert response.status_code == 400 assert "Date field is required" in response.json()["detail"] + @patch("health_sleep.router.sleep_scoring._calculate_and_set_sleep_scores") + @patch("health_sleep.router.health_sleep_crud.create_health_sleep") + @patch("health_sleep.router.health_sleep_crud.get_health_sleep_by_date") + def test_create_health_sleep_calls_scoring( + self, mock_get_by_date, mock_create, mock_scoring, fast_api_client, fast_api_app + ): + """ + Test creating health sleep calls sleep scoring function. + """ + # Arrange + mock_get_by_date.return_value = None + created_sleep = health_sleep_schema.HealthSleep( + id=1, + user_id=1, + date=datetime_date(2024, 1, 15), + total_sleep_seconds=28800, + ) + mock_create.return_value = created_sleep + + # Act + response = fast_api_client.post( + "/health_sleep", + json={ + "date": "2024-01-15", + "total_sleep_seconds": 28800, + }, + headers={"Authorization": "Bearer mock_token"}, + ) + + # Assert + assert response.status_code == 201 + mock_scoring.assert_called_once() + class TestEditHealthSleep: """ @@ -361,6 +394,44 @@ class TestEditHealthSleep: assert response.status_code == 404 +class TestEditHealthSleepScoringIntegration: + """ + Test suite for edit_health_sleep endpoint scoring integration. + """ + + @patch("health_sleep.router.sleep_scoring._calculate_and_set_sleep_scores") + @patch("health_sleep.router.health_sleep_crud.edit_health_sleep") + def test_edit_health_sleep_calls_scoring( + self, mock_edit, mock_scoring, fast_api_client, fast_api_app + ): + """ + Test editing health sleep calls sleep scoring function. + """ + # Arrange + updated_sleep = health_sleep_schema.HealthSleep( + id=1, + user_id=1, + date=datetime_date(2024, 1, 15), + total_sleep_seconds=32400, + ) + mock_edit.return_value = updated_sleep + + # Act + response = fast_api_client.put( + "/health_sleep", + json={ + "id": 1, + "date": "2024-01-15", + "total_sleep_seconds": 32400, + }, + headers={"Authorization": "Bearer mock_token"}, + ) + + # Assert + assert response.status_code == 200 + mock_scoring.assert_called_once() + + class TestDeleteHealthSleep: """ Test suite for delete_health_sleep endpoint. diff --git a/backend/tests/health_sleep/test_schema.py b/backend/tests/health_sleep/test_schema.py index 84f6ec3ec..634fe860a 100644 --- a/backend/tests/health_sleep/test_schema.py +++ b/backend/tests/health_sleep/test_schema.py @@ -26,7 +26,7 @@ class TestHealthSleepSchema: deep_sleep_seconds=7200, light_sleep_seconds=14400, rem_sleep_seconds=7200, - avg_heart_rate=Decimal("55.5"), + avg_heart_rate=55, min_heart_rate=45, max_heart_rate=75, sleep_score_overall=85, @@ -109,10 +109,10 @@ class TestHealthSleepSchema: light_sleep_seconds = 14400 rem_sleep_seconds = 7200 awake_sleep_seconds = 0 - avg_heart_rate = Decimal("55.5") + avg_heart_rate = 55 min_heart_rate = 45 max_heart_rate = 75 - avg_spo2 = Decimal("97.5") + avg_spo2 = 97 lowest_spo2 = 95 highest_spo2 = 99 avg_respiration = None @@ -166,13 +166,13 @@ class TestHealthSleepSchema: """ # Arrange & Act health_sleep = health_sleep_schema.HealthSleep( - avg_heart_rate=Decimal("60.5"), + avg_heart_rate=60, min_heart_rate=45, max_heart_rate=85, ) # Assert - assert health_sleep.avg_heart_rate == Decimal("60.5") + assert health_sleep.avg_heart_rate == 60 assert health_sleep.min_heart_rate == 45 assert health_sleep.max_heart_rate == 85 @@ -202,13 +202,13 @@ class TestHealthSleepSchema: """ # Arrange & Act health_sleep = health_sleep_schema.HealthSleep( - avg_spo2=Decimal("97.5"), + avg_spo2=97, lowest_spo2=95, highest_spo2=99, ) # Assert - assert health_sleep.avg_spo2 == Decimal("97.5") + assert health_sleep.avg_spo2 == 97 assert health_sleep.lowest_spo2 == 95 assert health_sleep.highest_spo2 == 99 diff --git a/backend/tests/health_sleep/test_sleep_scoring.py b/backend/tests/health_sleep/test_sleep_scoring.py new file mode 100644 index 000000000..afea89509 --- /dev/null +++ b/backend/tests/health_sleep/test_sleep_scoring.py @@ -0,0 +1,585 @@ +import pytest +from datetime import datetime +from unittest.mock import MagicMock + +import health_sleep.sleep_scoring as sleep_scoring +import health_sleep.schema as health_sleep_schema + + +class TestCalculateSleepDurationHours: + """ + Test suite for _calculate_sleep_duration_hours function. + """ + + def test_calculate_sleep_duration_hours_success(self): + """ + Test converting seconds to hours. + """ + # Act + result = sleep_scoring._calculate_sleep_duration_hours(28800) + + # Assert + assert result == 8.0 + + def test_calculate_sleep_duration_hours_none(self): + """ + Test converting None returns 0. + """ + # Act + result = sleep_scoring._calculate_sleep_duration_hours(None) + + # Assert + assert result == 0.0 + + +class TestCalculateStagePercentage: + """ + Test suite for _calculate_stage_percentage function. + """ + + def test_calculate_stage_percentage_success(self): + """ + Test calculating stage percentage. + """ + # Act + result = sleep_scoring._calculate_stage_percentage(7200, 28800) + + # Assert + assert result == 25.0 + + def test_calculate_stage_percentage_none_stage(self): + """ + Test calculating stage percentage with None stage. + """ + # Act + result = sleep_scoring._calculate_stage_percentage(None, 28800) + + # Assert + assert result == 0.0 + + def test_calculate_stage_percentage_zero_total(self): + """ + Test calculating stage percentage with zero total. + """ + # Act + result = sleep_scoring._calculate_stage_percentage(7200, 0) + + # Assert + assert result == 0.0 + + +class TestCalculateSleepScoreDuration: + """ + Test suite for calculate_sleep_score_duration function. + """ + + def test_calculate_sleep_score_duration_optimal_8_hours(self): + """ + Test duration score for optimal 8 hours sleep. + """ + # Arrange + start_time = datetime(2024, 1, 14, 22, 0, 0) + end_time = datetime(2024, 1, 15, 6, 0, 0) + total_seconds = 28800 # 8 hours + + # Act + score, label = sleep_scoring.calculate_sleep_score_duration( + start_time, end_time, total_seconds + ) + + # Assert + assert score == 100 + assert label == "EXCELLENT" + + def test_calculate_sleep_score_duration_7_hours(self): + """ + Test duration score for 7 hours sleep. + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_duration(None, None, 25200) + + # Assert + assert score == 90 + assert label == "EXCELLENT" + + def test_calculate_sleep_score_duration_6_hours(self): + """ + Test duration score for 6 hours sleep. + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_duration(None, None, 21600) + + # Assert + assert score == 70 + assert label == "GOOD" + + def test_calculate_sleep_score_duration_5_hours(self): + """ + Test duration score for 5 hours sleep. + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_duration(None, None, 18000) + + # Assert + assert score == 50 + assert label == "FAIR" + + def test_calculate_sleep_score_duration_4_hours(self): + """ + Test duration score for 4 hours sleep (poor). + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_duration(None, None, 14400) + + # Assert + assert label == "POOR" + assert score < 50 + + def test_calculate_sleep_score_duration_none(self): + """ + Test duration score with None total_sleep_seconds. + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_duration(None, None, None) + + # Assert + assert score == 0 + assert label == "POOR" + + +class TestCalculateSleepScoreQuality: + """ + Test suite for calculate_sleep_score_quality function. + """ + + def test_calculate_sleep_score_quality_optimal(self): + """ + Test quality score with optimal sleep stages. + """ + # Arrange + total_seconds = 28800 # 8 hours + deep_seconds = 5184 # 18% + rem_seconds = 6480 # 22.5% + light_seconds = 14400 # 50% + awake_seconds = 720 # 2.5% + + # Act + score, label = sleep_scoring.calculate_sleep_score_quality( + deep_seconds, + light_seconds, + rem_seconds, + awake_seconds, + total_seconds, + ) + + # Assert + assert score >= 90 + assert label == "EXCELLENT" + + def test_calculate_sleep_score_quality_none_total(self): + """ + Test quality score with None total sleep. + """ + # Act + score, label = sleep_scoring.calculate_sleep_score_quality( + None, None, None, None, None + ) + + # Assert + assert score == 0 + assert label == "POOR" + + def test_calculate_sleep_score_quality_poor_stages(self): + """ + Test quality score with poor sleep stage distribution. + """ + # Arrange + total_seconds = 28800 + deep_seconds = 1440 # 5% (too low) + rem_seconds = 2880 # 10% (too low) + light_seconds = 21600 # 75% (too high) + awake_seconds = 2880 # 10% (too high) + + # Act + score, label = sleep_scoring.calculate_sleep_score_quality( + deep_seconds, + light_seconds, + rem_seconds, + awake_seconds, + total_seconds, + ) + + # Assert + assert score < 70 + assert label in ["FAIR", "POOR"] + + +class TestCalculateAwakeCountScore: + """ + Test suite for calculate_awake_count_score function. + """ + + def test_calculate_awake_count_score_zero(self): + """ + Test awake count score with 0 awakenings. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(0) + + # Assert + assert score == 100 + assert label == "EXCELLENT" + + def test_calculate_awake_count_score_one(self): + """ + Test awake count score with 1 awakening. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(1) + + # Assert + assert score == 95 + assert label == "EXCELLENT" + + def test_calculate_awake_count_score_three(self): + """ + Test awake count score with 3 awakenings. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(3) + + # Assert + assert score == 79 + assert label == "GOOD" + + def test_calculate_awake_count_score_five(self): + """ + Test awake count score with 5 awakenings. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(5) + + # Assert + assert score == 59 + assert label == "FAIR" + + def test_calculate_awake_count_score_many(self): + """ + Test awake count score with many awakenings. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(10) + + # Assert + assert score < 50 + assert label == "POOR" + + def test_calculate_awake_count_score_none(self): + """ + Test awake count score with None. + """ + # Act + score, label = sleep_scoring.calculate_awake_count_score(None) + + # Assert + assert score == 50 + assert label == "FAIR" + + +class TestCalculateRemPercentageScore: + """ + Test suite for calculate_rem_percentage_score function. + """ + + def test_calculate_rem_percentage_score_optimal(self): + """ + Test REM percentage score with optimal value. + """ + # Arrange + total_seconds = 28800 + rem_seconds = 6480 # 22.5% + + # Act + score, label = sleep_scoring.calculate_rem_percentage_score( + rem_seconds, total_seconds + ) + + # Assert + assert score >= 90 + assert label == "EXCELLENT" + + def test_calculate_rem_percentage_score_good(self): + """ + Test REM percentage score with good value. + """ + # Arrange + total_seconds = 28800 + rem_seconds = 6048 # 21% + + # Act + score, label = sleep_scoring.calculate_rem_percentage_score( + rem_seconds, total_seconds + ) + + # Assert + assert score >= 80 + assert label in ["EXCELLENT", "GOOD"] + + def test_calculate_rem_percentage_score_none_total(self): + """ + Test REM percentage score with None total. + """ + # Act + score, label = sleep_scoring.calculate_rem_percentage_score(None, None) + + # Assert + assert score == 0 + assert label == "POOR" + + +class TestCalculateDeepPercentageScore: + """ + Test suite for calculate_deep_percentage_score function. + """ + + def test_calculate_deep_percentage_score_optimal(self): + """ + Test deep percentage score with optimal value. + """ + # Arrange + total_seconds = 28800 + deep_seconds = 5184 # 18% + + # Act + score, label = sleep_scoring.calculate_deep_percentage_score( + deep_seconds, total_seconds + ) + + # Assert + assert score >= 90 + assert label == "EXCELLENT" + + def test_calculate_deep_percentage_score_none_total(self): + """ + Test deep percentage score with None total. + """ + # Act + score, label = sleep_scoring.calculate_deep_percentage_score(None, None) + + # Assert + assert score == 0 + assert label == "POOR" + + +class TestCalculateLightPercentageScore: + """ + Test suite for calculate_light_percentage_score function. + """ + + def test_calculate_light_percentage_score_optimal(self): + """ + Test light percentage score with optimal value. + """ + # Arrange + total_seconds = 28800 + light_seconds = 14400 # 50% + + # Act + score, label = sleep_scoring.calculate_light_percentage_score( + light_seconds, total_seconds + ) + + # Assert + assert score >= 90 + assert label == "EXCELLENT" + + def test_calculate_light_percentage_score_none_total(self): + """ + Test light percentage score with None total. + """ + # Act + score, label = sleep_scoring.calculate_light_percentage_score(None, None) + + # Assert + assert score == 0 + assert label == "POOR" + + +class TestCalculateSleepStressScore: + """ + Test suite for calculate_sleep_stress_score function. + """ + + def test_calculate_sleep_stress_score_low_stress(self): + """ + Test sleep stress score with low stress. + """ + # Act + score, label = sleep_scoring.calculate_sleep_stress_score(20, 2) + + # Assert + assert score >= 90 + assert label in ["EXCELLENT", "GOOD"] + + def test_calculate_sleep_stress_score_medium_stress(self): + """ + Test sleep stress score with medium stress. + """ + # Act + score, label = sleep_scoring.calculate_sleep_stress_score(60, 3) + + # Assert + assert score >= 50 + assert label in ["GOOD", "FAIR"] + + def test_calculate_sleep_stress_score_high_stress(self): + """ + Test sleep stress score with high stress. + """ + # Act + score, label = sleep_scoring.calculate_sleep_stress_score(85, 8) + + # Assert + assert score < 50 + assert label == "POOR" + + def test_calculate_sleep_stress_score_none(self): + """ + Test sleep stress score with None stress. + """ + # Act + score, label = sleep_scoring.calculate_sleep_stress_score(None, None) + + # Assert + assert score == 50 + assert label == "FAIR" + + +class TestCalculateSleepScoreOverall: + """ + Test suite for calculate_sleep_score_overall function. + """ + + def test_calculate_sleep_score_overall_excellent(self): + """ + Test overall sleep score with excellent sleep. + """ + # Arrange + start_time = datetime(2024, 1, 14, 22, 0, 0) + end_time = datetime(2024, 1, 15, 6, 0, 0) + total_seconds = 28800 # 8 hours + deep_seconds = 5184 # 18% + light_seconds = 14400 # 50% + rem_seconds = 6480 # 22.5% + awake_seconds = 720 # 2.5% + awake_count = 1 + restless_count = 2 + avg_stress = 20 + + # Act + score = sleep_scoring.calculate_sleep_score_overall( + start_time, + end_time, + total_seconds, + deep_seconds, + light_seconds, + rem_seconds, + awake_seconds, + awake_count, + restless_count, + avg_stress, + ) + + # Assert + assert score >= 85 + assert score <= 100 + + def test_calculate_sleep_score_overall_poor(self): + """ + Test overall sleep score with poor sleep. + """ + # Arrange + start_time = datetime(2024, 1, 14, 23, 0, 0) + end_time = datetime(2024, 1, 15, 3, 0, 0) + total_seconds = 14400 # 4 hours + deep_seconds = 1440 # 10% + light_seconds = 10800 # 75% + rem_seconds = 1440 # 10% + awake_seconds = 720 # 5% + awake_count = 8 + restless_count = 12 + avg_stress = 85 + + # Act + score = sleep_scoring.calculate_sleep_score_overall( + start_time, + end_time, + total_seconds, + deep_seconds, + light_seconds, + rem_seconds, + awake_seconds, + awake_count, + restless_count, + avg_stress, + ) + + # Assert + assert score >= 0 + assert score < 50 + + +class TestCalculateAndSetSleepScores: + """ + Test suite for _calculate_and_set_sleep_scores function. + """ + + def test_calculate_and_set_sleep_scores_success(self): + """ + Test calculating and setting all sleep scores. + """ + # Arrange + health_sleep = health_sleep_schema.HealthSleep( + sleep_start_time_local=datetime(2024, 1, 14, 22, 0, 0), + sleep_end_time_local=datetime(2024, 1, 15, 6, 0, 0), + total_sleep_seconds=28800, + deep_sleep_seconds=5184, + light_sleep_seconds=14400, + rem_sleep_seconds=6480, + awake_sleep_seconds=720, + awake_count=1, + restless_moments_count=2, + avg_sleep_stress=20, + ) + + # Act + sleep_scoring._calculate_and_set_sleep_scores(health_sleep) + + # Assert + assert health_sleep.sleep_score_overall is not None + assert health_sleep.sleep_score_overall >= 0 + assert health_sleep.sleep_score_overall <= 100 + assert health_sleep.sleep_score_duration is not None + assert health_sleep.sleep_score_quality is not None + assert health_sleep.awake_count_score is not None + assert health_sleep.rem_percentage_score is not None + assert health_sleep.deep_percentage_score is not None + assert health_sleep.light_percentage_score is not None + assert health_sleep.sleep_stress_score is not None + + def test_calculate_and_set_sleep_scores_minimal_data(self): + """ + Test calculating scores with minimal data. + """ + # Arrange + health_sleep = health_sleep_schema.HealthSleep( + total_sleep_seconds=28800, + ) + + # Act + sleep_scoring._calculate_and_set_sleep_scores(health_sleep) + + # Assert + assert health_sleep.sleep_score_overall is not None + assert health_sleep.sleep_score_duration is not None + assert health_sleep.sleep_score_quality is not None diff --git a/docs/getting-started/sleep-scoring.md b/docs/getting-started/sleep-scoring.md new file mode 100644 index 000000000..cf1250dbf --- /dev/null +++ b/docs/getting-started/sleep-scoring.md @@ -0,0 +1,288 @@ +# Sleep Scoring Guide + +Endurain uses a sleep scoring system to help you understand the quality of your sleep. This guide explains how your sleep is evaluated and what the scores mean. + +## What is Sleep Scoring? + +Sleep scoring is like a report card for your sleep. The system analyzes your sleep data and gives you: + +- **An overall sleep score** (0-100 points) +- **Individual component scores** with quality labels +- **Easy-to-understand ratings**: Excellent, Good, Fair, or Poor + +## How Are Scores Calculated? + +### Overall Sleep Score (0-100) + +Your overall sleep score combines several important factors: + +- **30%** - How long you slept (Duration) +- **40%** - How well you slept (Quality) +- **10%** - How many times you woke up +- **20%** - Your stress levels during sleep + +**What the scores mean:** + +- **90-100**: Excellent sleep! You're well-rested and recharged +- **70-89**: Good sleep. You got decent rest with room for improvement +- **50-69**: Fair sleep. You might feel somewhat rested but could do better +- **0-49**: Poor sleep. You likely feel tired and need better rest + +--- + +## Understanding Each Score Component + +### 1. Sleep Duration Score + +**What it measures:** How many hours you slept + +**Why it matters:** Your body needs enough time to go through all sleep stages and recover properly. + +**Scoring guidelines:** + +| Hours of Sleep | Score | Rating | +|----------------|-------|--------| +| 7-9 hours | 90-100 | **EXCELLENT** - Perfect amount! | +| 6-7 hours | 70-89 | **GOOD** - A bit short but okay | +| 9-10 hours | 70-89 | **GOOD** - A bit long but okay | +| 5-6 hours | 50-69 | **FAIR** - Not enough rest | +| 10-11 hours | 50-69 | **FAIR** - Possibly oversleeping | +| Less than 5 hours | 0-49 | **POOR** - Seriously insufficient | +| More than 11 hours | 0-49 | **POOR** - Too much sleep | + +**The sweet spot:** 7-9 hours, with 8 hours being ideal for most adults. + +--- + +### 2. Sleep Quality Score + +**What it measures:** How your sleep was distributed across different sleep stages + +**Why it matters:** Quality sleep isn't just about quantity - your brain and body need the right mix of sleep stages to fully recover. + +**The four sleep stages:** + +1. **Light Sleep** - Transition period, your body starts to relax +2. **Deep Sleep** - Body repairs muscles and tissues, strengthens immune system +3. **REM Sleep** - Brain processes memories and emotions, vivid dreams occur +4. **Awake** - Brief wakeful moments (some are normal!) + +**Optimal percentages:** + +| Sleep Stage | Ideal Range | Why It Matters | +|-------------|-------------|----------------| +| **Deep Sleep** | 13-23% (peak: 18%) | Physical recovery and healing | +| **REM Sleep** | 20-25% (peak: 22.5%) | Memory and emotional processing | +| **Light Sleep** | 45-55% (peak: 50%) | Transition between stages | +| **Awake Time** | Less than 5% | Normal brief awakenings | + +**How it's scored:** + +- Each sleep stage gets its own score based on how close you are to the optimal percentage +- The scores are combined with these weights: + - Deep sleep: 25% + - REM sleep: 30% (most important for mental recovery!) + - Light sleep: 25% + - Awake time: 20% (penalty applied) + +--- + +### 3. Awake Count Score + +**What it measures:** How many times you woke up during the night + +**Why it matters:** Frequent awakenings disrupt your sleep cycles and prevent deep, restorative sleep. + +**Scoring:** + +| Awakenings | Score | Rating | What It Means | +|------------|-------|--------|---------------| +| 0-1 times | 90-100 | **EXCELLENT** | Uninterrupted, restorative sleep | +| 2-3 times | 70-89 | **GOOD** | Some interruptions but still decent | +| 4-5 times | 50-69 | **FAIR** | Sleep continuity affected | +| 6+ times | 0-49 | **POOR** | Very fragmented sleep | + +**Note:** It's normal to wake up briefly 1-2 times per night. You might not even remember them! + +--- + +### 4. REM Sleep Percentage Score + +**What it measures:** What percentage of your sleep was REM (Rapid Eye Movement) sleep + +**Why it matters:** REM sleep is when your brain consolidates memories, processes emotions, and boosts creativity. It's essential for mental health and learning. + +**Optimal range:** 20-25% of total sleep time (about 1.5-2 hours for 8 hours of sleep) + +**What different levels mean:** + +- **Too low (<20%)**: May affect memory, mood, and cognitive function +- **Optimal (20-25%)**: Brain is fully processing and recovering +- **Too high (>25%)**: Unusual and may indicate sleep disorders + +--- + +### 5. Deep Sleep Percentage Score + +**What it measures:** What percentage of your sleep was deep sleep + +**Why it matters:** Deep sleep is when your body does most of its physical repair - healing muscles, strengthening bones, and boosting your immune system. + +**Optimal range:** 13-23% of total sleep time (about 1-2 hours for 8 hours of sleep) + +**What different levels mean:** + +- **Too low (<13%)**: Less physical recovery, may feel physically tired +- **Optimal (13-23%)**: Body is fully recovering and strengthening +- **Too high (>23%)**: Rare, but may indicate sleep debt recovery + +**Did you know?** Deep sleep decreases naturally as you age, which is normal! + +--- + +### 6. Light Sleep Percentage Score + +**What it measures:** What percentage of your sleep was light sleep + +**Why it matters:** Light sleep serves as a transition between sleep stages and makes up the largest portion of your sleep. + +**Optimal range:** 45-55% of total sleep time (about 3.5-4.5 hours for 8 hours of sleep) + +**What different levels mean:** + +- **Too low (<45%)**: You may be spending too much time in other stages +- **Optimal (45-55%)**: Healthy balance of sleep stages +- **Too high (>55%)**: May not be getting enough deep or REM sleep + +--- + +### 7. Sleep Stress Score + +**What it measures:** Your average stress level during sleep and how restless you were + +**Why it matters:** High stress during sleep indicates your body isn't fully relaxing, which affects recovery quality. + +**Stress levels explained** (based on Garmin scale): + +- **0-25**: Rest state - fully relaxed +- **26-50**: Low stress - mostly relaxed +- **51-75**: Medium stress - moderately elevated +- **76-100**: High stress - not relaxing properly + +**Scoring:** + +| Stress Level | Base Score | Rating | +|--------------|-----------|--------| +| 0-25 (Rest) | 100 | **EXCELLENT** | +| 26-50 (Low) | 70-90 | **GOOD** | +| 51-75 (Medium) | 50-70 | **FAIR** | +| 76-100 (High) | 0-50 | **POOR** | + +**Restless moments penalty:** Each restless moment during sleep reduces your score by 2-3 points. + +--- + +## Tips for Better Sleep Scores + +### To Improve Duration Score: +- Aim for 7-9 hours of sleep per night +- Keep a consistent sleep schedule, even on weekends. Be consistent on the time you go to bed and awake not only the sleep hours +- Go to bed when you're tired, not too early or late + +### To Improve Quality Score: +- Create a dark, quiet, cool sleeping environment +- Avoid screens and high intensity brain tasks 1 hour before bedtime (blue light disrupts sleep) +- Avoid caffeine 6 hours before bed +- Exercise regularly, but not right before bed + +### To Reduce Awakenings: +- Limit fluid intake 2 hours before bedtime +- Keep your bedroom cool (60-67°F / 15-19°C is ideal) +- Use white noise or earplugs if needed +- Address any underlying sleep disorders with a doctor + +### To Reduce Sleep Stress: +- Practice relaxation techniques before bed (meditation, deep breathing) +- Keep a regular exercise routine during the day +- Avoid stressful activities or conversations before bedtime +- Consider journaling to clear your mind before sleep + +--- + +## Frequently Asked Questions + +### Q: Why is my score low even though I slept 8 hours? + +**A:** Duration is only 30% of your overall score. You might have had poor quality sleep, many awakenings, or high stress levels. Check your individual component scores to see what needs improvement. + +### Q: Is it bad if my scores vary day to day? + +**A:** Some variation is normal! Factors like stress, exercise, diet, and life events affect your sleep. Look for trends over weeks rather than individual nights. + +### Q: What's more important - duration or quality? + +**A:** Both matter! Quality is weighted slightly higher (40% vs 30%) because you can sleep for 10 hours but still feel tired if the quality is poor. Aim for both good duration AND quality. + +### Q: My REM/Deep sleep percentage seems low. Is that bad? + +**A:** Not necessarily. These percentages are averages based on research. Individual needs vary by age, genetics, and lifestyle. If you feel rested and energetic, your sleep is probably fine! + +### Q: Can I compare my scores with friends? + +**A:** While you can, remember that everyone's sleep needs are different. Focus on improving YOUR scores over time rather than comparing with others. + +### Q: What if I have a sleep disorder? + +**A:** These scores are educational tools, not medical diagnoses. If you consistently get poor scores or feel tired despite good scores, consult a healthcare professional or sleep specialist. + +--- + +## Understanding Your Data + +### When are scores calculated? + +Scores are automatically calculated when you: + +- Create a new sleep record +- Update an existing sleep record +- Important: If sleep data is imported from Garmin Connect, no calculations are made. Garmin Connect data is used + +The system recalculates all scores to ensure they reflect your current data. + +### What data is needed? + +For the most accurate scores, provide: + +- ✅ Sleep start and end times +- ✅ Total sleep duration +- ✅ Sleep stage breakdowns (deep, light, REM, awake) +- ✅ Number of awakenings +- ✅ Stress levels (optional but recommended) + +**Partial data:** Even with incomplete data, the system will calculate scores for the metrics you provide. Missing metrics won't break the scoring system. + +--- + +## Technical Details + +For developers and technically-minded users: + +- **Algorithm**: Based on sleep research (provided by Claude Sonnet 4.5 and double checked by me) +- **Scoring method**: Weighted average of normalized component scores +- **Data source**: Compatible with Garmin Connect and manual entry +- **Score range**: All scores normalized to 0-100 scale +- **Labels**: Threshold-based categorization (90+=Excellent, 70+=Good, 50+=Fair, <50=Poor) + +--- + +## Summary + +Your sleep scores provide valuable insights into your rest quality. By understanding what each score means and following the improvement tips, you can work towards better, more restorative sleep. + +**Remember:** +- 🎯 Focus on trends, not single nights +- 💪 Small improvements add up over time +- 😴 Consistency is key to good sleep +- 🩺 Consult a doctor for persistent sleep issues + +Sweet dreams and happy tracking! 🌙✨ diff --git a/frontend/app/src/components/Health/HealthSleepZone/HealthSleepAddEditModalComponent.vue b/frontend/app/src/components/Health/HealthSleepZone/HealthSleepAddEditModalComponent.vue index 2c4657d05..80d870d92 100644 --- a/frontend/app/src/components/Health/HealthSleepZone/HealthSleepAddEditModalComponent.vue +++ b/frontend/app/src/components/Health/HealthSleepZone/HealthSleepAddEditModalComponent.vue @@ -124,38 +124,21 @@ - -
- -
- - {{ $t('generalItems.labelHours') }} - - {{ $t('generalItems.labelMinutes') }} -
-
-
- +
-
@@ -166,7 +149,7 @@ {{ $t('healthSleepAddEditModalComponent.avgHeartRateLabel') }} + class="form-control" type="number" v-model.number="formData.avgHeartRate" />
+ class="form-control" type="number" v-model.number="formData.avgSpo2" />
-
+
@@ -255,7 +238,7 @@
-
+
@@ -263,20 +246,29 @@ v-model="stage.startTimeGmt" />
-
+
- -
-
+
+
+ - +
+ + {{ $t('generalItems.labelHoursShort') }} + + {{ $t('generalItems.labelMinutesShort') }} +
@@ -318,7 +310,8 @@ interface SleepStage { stageType: number | null startTimeGmt: string | null endTimeGmt: string | null - durationSeconds: number | null + durationHours: number | null + durationMinutes: number | null } interface SleepFormData { @@ -327,8 +320,6 @@ interface SleepFormData { sleepEndTime: string totalSleepHours: number | null totalSleepMinutes: number | null - unmeasurableSleepHours: number | null - unmeasurableSleepMinutes: number | null deepSleepHours: number | null deepSleepMinutes: number | null lightSleepHours: number | null @@ -344,7 +335,7 @@ interface SleepFormData { lowestSpo2: number | null highestSpo2: number | null awakeCount: number | null - restlessMomentsCount: number | null + avgSleepStress: number | null sleepStages: SleepStage[] } @@ -354,8 +345,8 @@ interface UserHealthSleep { date: string sleep_start_time_gmt: string sleep_end_time_gmt: string - sleep_start_time_local?: string - sleep_end_time_local?: string + sleep_start_time_local: string + sleep_end_time_local: string total_sleep_seconds?: number unmeasurable_sleep_seconds?: number deep_sleep_seconds?: number @@ -369,7 +360,7 @@ interface UserHealthSleep { lowest_spo2?: number highest_spo2?: number awake_count?: number - restless_moments_count?: number + avg_sleep_stress?: number sleep_stages?: Array<{ stage_type: number | null start_time_gmt: string | null @@ -397,8 +388,6 @@ const formData = ref({ sleepEndTime: '', totalSleepHours: null, totalSleepMinutes: null, - unmeasurableSleepHours: null, - unmeasurableSleepMinutes: null, deepSleepHours: null, deepSleepMinutes: null, lightSleepHours: null, @@ -414,7 +403,7 @@ const formData = ref({ lowestSpo2: null, highestSpo2: null, awakeCount: null, - restlessMomentsCount: null, + avgSleepStress: null, sleepStages: [] }) @@ -433,12 +422,10 @@ onMounted(() => { formData.value = { date: props.userHealthSleep.date, - sleepStartTime: formatDateTimeForInput(props.userHealthSleep.sleep_start_time_gmt), - sleepEndTime: formatDateTimeForInput(props.userHealthSleep.sleep_end_time_gmt), + sleepStartTime: formatDateTimeForInput(props.userHealthSleep.sleep_start_time_local), + sleepEndTime: formatDateTimeForInput(props.userHealthSleep.sleep_end_time_local), totalSleepHours: totalSleep.hours, totalSleepMinutes: totalSleep.minutes, - unmeasurableSleepHours: unmeasurableSleep.hours, - unmeasurableSleepMinutes: unmeasurableSleep.minutes, deepSleepHours: deepSleep.hours, deepSleepMinutes: deepSleep.minutes, lightSleepHours: lightSleep.hours, @@ -454,14 +441,18 @@ onMounted(() => { lowestSpo2: props.userHealthSleep.lowest_spo2 ?? null, highestSpo2: props.userHealthSleep.highest_spo2 ?? null, awakeCount: props.userHealthSleep.awake_count ?? null, - restlessMomentsCount: props.userHealthSleep.restless_moments_count ?? null, + avgSleepStress: props.userHealthSleep.avg_sleep_stress ?? null, sleepStages: - props.userHealthSleep.sleep_stages?.map((stage) => ({ - stageType: stage.stage_type, - startTimeGmt: stage.start_time_gmt ? formatDateTimeForInput(stage.start_time_gmt) : null, - endTimeGmt: stage.end_time_gmt ? formatDateTimeForInput(stage.end_time_gmt) : null, - durationSeconds: stage.duration_seconds - })) ?? [] + props.userHealthSleep.sleep_stages?.map((stage) => { + const duration = returnHoursMinutesFromSeconds(stage.duration_seconds ?? 0) + return { + stageType: stage.stage_type, + startTimeGmt: stage.start_time_gmt ? formatDateTimeForInput(stage.start_time_gmt) : null, + endTimeGmt: stage.end_time_gmt ? formatDateTimeForInput(stage.end_time_gmt) : null, + durationHours: duration.hours, + durationMinutes: duration.minutes + } + }) ?? [] } editSleepId.value = `editSleepId${props.userHealthSleep.id}` } @@ -485,7 +476,8 @@ function addSleepStage(): void { stageType: 1, startTimeGmt: null, endTimeGmt: null, - durationSeconds: null + durationHours: null, + durationMinutes: null }) } @@ -508,8 +500,8 @@ async function submitAddSleep(): Promise { try { const data = { date: formData.value.date, - sleep_start_time_gmt: formData.value.sleepStartTime, - sleep_end_time_gmt: formData.value.sleepEndTime, + sleep_start_time_local: formData.value.sleepStartTime, + sleep_end_time_local: formData.value.sleepEndTime, total_sleep_seconds: formData.value.totalSleepHours !== null && formData.value.totalSleepMinutes !== null ? returnSecondsFromHoursMinutes( @@ -517,14 +509,6 @@ async function submitAddSleep(): Promise { formData.value.totalSleepMinutes ) : null, - unmeasurable_sleep_seconds: - formData.value.unmeasurableSleepHours !== null && - formData.value.unmeasurableSleepMinutes !== null - ? returnSecondsFromHoursMinutes( - formData.value.unmeasurableSleepHours, - formData.value.unmeasurableSleepMinutes - ) - : null, deep_sleep_seconds: formData.value.deepSleepHours !== null && formData.value.deepSleepMinutes !== null ? returnSecondsFromHoursMinutes( @@ -560,12 +544,15 @@ async function submitAddSleep(): Promise { lowest_spo2: formData.value.lowestSpo2, highest_spo2: formData.value.highestSpo2, awake_count: formData.value.awakeCount, - restless_moments_count: formData.value.restlessMomentsCount, + avg_sleep_stress: formData.value.avgSleepStress, sleep_stages: formData.value.sleepStages.map((stage) => ({ stage_type: stage.stageType, start_time_gmt: stage.startTimeGmt, end_time_gmt: stage.endTimeGmt, - duration_seconds: stage.durationSeconds + duration_seconds: + stage.durationHours !== null && stage.durationMinutes !== null + ? returnSecondsFromHoursMinutes(stage.durationHours, stage.durationMinutes) + : null })) } @@ -593,8 +580,8 @@ function submitEditSleep(): void { id: props.userHealthSleep.id, user_id: props.userHealthSleep.user_id, date: formData.value.date, - sleep_start_time_gmt: formData.value.sleepStartTime, - sleep_end_time_gmt: formData.value.sleepEndTime, + sleep_start_time_local: formData.value.sleepStartTime, + sleep_end_time_local: formData.value.sleepEndTime, total_sleep_seconds: formData.value.totalSleepHours !== null && formData.value.totalSleepMinutes !== null ? returnSecondsFromHoursMinutes( @@ -602,14 +589,6 @@ function submitEditSleep(): void { formData.value.totalSleepMinutes ) : null, - unmeasurable_sleep_seconds: - formData.value.unmeasurableSleepHours !== null && - formData.value.unmeasurableSleepMinutes !== null - ? returnSecondsFromHoursMinutes( - formData.value.unmeasurableSleepHours, - formData.value.unmeasurableSleepMinutes - ) - : null, deep_sleep_seconds: formData.value.deepSleepHours !== null && formData.value.deepSleepMinutes !== null ? returnSecondsFromHoursMinutes( @@ -645,12 +624,15 @@ function submitEditSleep(): void { lowest_spo2: formData.value.lowestSpo2, highest_spo2: formData.value.highestSpo2, awake_count: formData.value.awakeCount, - restless_moments_count: formData.value.restlessMomentsCount, + avg_sleep_stress: formData.value.avgSleepStress, sleep_stages: formData.value.sleepStages.map((stage) => ({ stage_type: stage.stageType, start_time_gmt: stage.startTimeGmt, end_time_gmt: stage.endTimeGmt, - duration_seconds: stage.durationSeconds + duration_seconds: + stage.durationHours !== null && stage.durationMinutes !== null + ? returnSecondsFromHoursMinutes(stage.durationHours, stage.durationMinutes) + : null })) } diff --git a/frontend/app/src/i18n/pt/components/health/healthSleepZone/healthSleepAddEditModalComponent.json b/frontend/app/src/i18n/pt/components/health/healthSleepZone/healthSleepAddEditModalComponent.json index e5ee0473d..3ba1fbe72 100644 --- a/frontend/app/src/i18n/pt/components/health/healthSleepZone/healthSleepAddEditModalComponent.json +++ b/frontend/app/src/i18n/pt/components/health/healthSleepZone/healthSleepAddEditModalComponent.json @@ -18,7 +18,7 @@ "highestSpo2Label": "SpO2 máximo", "sleepScoreOverallLabel": "Pontuação geral do sono", "awakeCountLabel": "Número de despertares", - "restlessMomentsCountLabel": "Momentos inquietos", + "avgSleepStressLabel": "Stress Médio do Sono", "sleepStagesLabel": "Fases do sono", "addStageButton": "Adicionar fase", "stageLabel": "Fase", @@ -29,7 +29,7 @@ "stageTypeAwake": "Acordado", "stageStartTimeLabel": "Hora de início", "stageEndTimeLabel": "Hora de fim", - "stageDurationLabel": "Duração (segundos)", + "stageDurationLabel": "Duração", "noStagesMessage": "Nenhuma fase de sono adicionada ainda. Clique em 'Adicionar fase' para adicionar uma fase de sono.", "successAddSleep": "Sono adicionado com sucesso", "errorAddSleep": "Erro ao adicionar sono" diff --git a/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepAddEditModalComponent.json b/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepAddEditModalComponent.json index 971ba1522..311771e71 100644 --- a/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepAddEditModalComponent.json +++ b/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepAddEditModalComponent.json @@ -9,7 +9,6 @@ "lightSleepLabel": "Light sleep", "remSleepLabel": "REM sleep", "awakeSleepLabel": "Awake sleep", - "unmeasurableSleepLabel": "Unmeasurable sleep", "avgHeartRateLabel": "Avg HR", "minHeartRateLabel": "Min HR", "maxHeartRateLabel": "Max HR", @@ -18,7 +17,7 @@ "highestSpo2Label": "Max SpO2", "sleepScoreOverallLabel": "Overall sleep score", "awakeCountLabel": "Awake count", - "restlessMomentsCountLabel": "Restless moments", + "avgSleepStressLabel": "Avg Sleep Stress", "sleepStagesLabel": "Sleep stages", "addStageButton": "Add stage", "stageLabel": "Stage", @@ -29,7 +28,7 @@ "stageTypeAwake": "Awake", "stageStartTimeLabel": "Start time", "stageEndTimeLabel": "End time", - "stageDurationLabel": "Duration (seconds)", + "stageDurationLabel": "Duration", "noStagesMessage": "No sleep stages added yet. Click 'Add stage' to add a sleep stage.", "successAddSleep": "Sleep added successfully", "errorAddSleep": "Error adding sleep" diff --git a/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepListTabsComponent.json b/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepListTabsComponent.json index 10ccce5af..19f8ead78 100644 --- a/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepListTabsComponent.json +++ b/frontend/app/src/i18n/us/components/health/healthSleepZone/healthSleepListTabsComponent.json @@ -19,7 +19,7 @@ "goodLabel": "Good", "fairLabel": "Fair", "heartRateTitle": "Heart Rate", - "restingHeartRateLabel": "Resting", + "restingHeartRateLabel": "Resting HR", "avgLabel": "Avg.", "maxLabel": "Max.", "minLabel": "Min.", @@ -27,4 +27,4 @@ "spo2Title": "Blood Oxygen Saturation (SpO2)", "avgSkinTempDeviationLabel": "Avg. Skin Temp. Deviation", "avgSleepStressLabel": "Avg. Sleep Stress" -} +} \ No newline at end of file diff --git a/frontend/app/src/views/HealthView.vue b/frontend/app/src/views/HealthView.vue index ad288c44c..e5b3c1c52 100644 --- a/frontend/app/src/views/HealthView.vue +++ b/frontend/app/src/views/HealthView.vue @@ -2,79 +2,40 @@

{{ $t('healthView.title') }}

- + - + - + - + - + - +
@@ -167,6 +128,28 @@ async function fetchHealthSleep() { } } +function updateSleepListAdded(createdSleep) { + const updateOrAdd = (array, newEntry) => { + const index = array.findIndex((item) => item.id === newEntry.id) + if (index !== -1) { + array[index] = newEntry + } else { + array.unshift(newEntry) + } + } + if (userHealthSleepPagination.value) { + updateOrAdd(userHealthSleepPagination.value, createdSleep) + } else { + userHealthSleepPagination.value = [createdSleep] + } + if (userHealthSleep.value) { + updateOrAdd(userHealthSleep.value, createdSleep) + } else { + userHealthSleep.value = [createdSleep] + } + userHealthSleepNumber.value = userHealthSleep.value.length +} + function updateSleepListEdited(editedSleep) { const indexPagination = userHealthSleepPagination.value.findIndex( (sleep) => sleep.id === editedSleep.id