Skip to content

feat: Sleep score recalculation after sleep session merge - #884

Merged
KaliszS merged 4 commits into
mainfrom
sleep-merge-score-recalculation
May 7, 2026
Merged

KaliszS merged 4 commits into
mainfrom
sleep-merge-score-recalculation

Conversation

@czajkub

@czajkub czajkub commented Apr 16, 2026 •

Copy link
Copy Markdown
Collaborator

Description

When another sleep session is merged (or an incoming one), the sleep score stays the same (even if e.g. the sleep stages changed), and now it will be correctly recalculating afterwards

Checklist

General

  • My code follows the project's code style
  • I have performed a self-review of my code
  • I have added tests that prove my fix/feature works (if applicable)
  • New and existing tests pass locally
  • I have updated relevant documentation in docs/ (or no docs update needed)

Backend Changes

You have to be in backend directory to make it work:

  • uv run pre-commit run --all-files passes

Summary by CodeRabbit

  • Improvements
    • Sleep scores are now automatically recalculated whenever sleep records are created, updated, merged, or re-ingested.
    • Internal per-day sleep scores for affected dates are removed and repopulated to keep assessments accurate.
    • Edge cases around merged, replaced, or re-ingested sleep sessions are handled to ensure consistent, up-to-date health scoring.

@czajkub czajkub added backend Tickets related to the backend (FastAPI, Celery, database) health-scores Issues related to Health Scores (like Sleep Score, Stress Level etc.) labels Apr 16, 2026
@coderabbitai

coderabbitai Bot commented Apr 16, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: edce07bc-118f-4440-b25f-4acc65f2fa3c

📥 Commits

Reviewing files that changed from the base of the PR and between dd9082b and 6ae221f.

📒 Files selected for processing (1)
  • backend/app/services/event_record_service.py

📝 Walkthrough

Walkthrough

Adds a bulk-delete method to HealthScoreRepository to remove internal health scores for a user/date/category/provider and extends EventRecordService to compute a session's local date, delete stale internal SLEEP scores for affected local dates, and repopulate per-date sleep scores via the sleep scoring service during sleep create/merge/update flows.

Changes

Cohort / File(s) Summary
Health Score Repository
backend/app/repositories/health_score_repository.py
Added delete_for_user_date(db_session, user_id, score_date, category, provider="internal") -> int which computes UTC-midnight for the given date and issues a bulk SQLAlchemy DELETE matching user_id, category, provider, and recorded_at equality; returns deleted row count; caller commits.
Event Record Service
backend/app/services/event_record_service.py
Added _local_sleep_date(start_datetime, zone_offset), _recompute_sleep_scores(...), and integrated recompute calls into multiple branches of _create_or_merge_sleep_inner(...); deletes existing internal SLEEP health scores for affected local dates, queries sleep_score_service for per-date scores, and bulk-creates new internal HealthScore rows (overall + components). Imported enums/types and wired repository calls.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    participant ERS as EventRecordService
    participant HSR as HealthScoreRepository
    participant SSS as SleepScoreService
    participant DB as Database

    ERS->>ERS: detect sleep create/merge/update\ncompute local_date = _local_sleep_date(start_datetime, zone_offset)
    ERS->>HSR: delete_for_user_date(user_id, local_date, category=SLEEP, provider="internal")
    HSR->>DB: DELETE FROM health_scores WHERE user_id/recorded_at/category/provider
    DB-->>HSR: deleted_count
    HSR-->>ERS: deleted_count

    ERS->>SSS: get_sleep_scores_for_date(user_id, local_date)
    SSS->>DB: query sleep events for user/local_date
    DB-->>SSS: sleep event data
    SSS-->>ERS: score components (overall, duration, stages, consistency, interruptions)

    ERS->>HSR: bulk_create HealthScore rows (internal, components) recorded_at=UTC-midnight
    HSR->>DB: INSERT INTO health_scores (...)
    DB-->>HSR: created_count
    HSR-->>ERS: created_count
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • bartmichalak
  • KaliszS

Poem

🐰
I hop at dusk to mark the date,
Midnight crumbs I tidy straight,
Old sleep rows cleared, new scores sown,
Component carrots neatly grown,
Recomputed, snug — a hop back home! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding sleep score recalculation logic when sleep sessions are merged, which is the primary feature introduced across both repository and service modifications.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sleep-merge-score-recalculation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@railway-app

railway-app Bot commented Apr 16, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the open-wearables-pr-884 environment in Open Wearables

Service Status Web Updated (UTC)
celery-beat ✅ Success (View Logs) Apr 23, 2026 at 11:22 am
celery-flower ✅ Success (View Logs) Web Apr 23, 2026 at 11:22 am
backend ✅ Success (View Logs) Web Apr 23, 2026 at 11:22 am
celery-worker ✅ Success (View Logs) Apr 23, 2026 at 11:22 am
frontend ✅ Success (View Logs) Web Apr 23, 2026 at 11:21 am

@railway-app
railway-app Bot temporarily deployed to Open Wearables / open-wearables-pr-884 April 16, 2026 10:22 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/app/services/event_record_service.py`:
- Around line 133-167: _recompute_sleep_score only recomputes the sleep score
for the post-update local date, leaving a stale score on the old local date when
a session is shifted across midnight; change the API and callers to recompute
both affected local dates: update _recompute_sleep_score to accept an iterable
(or rename to _recompute_sleep_scores) and for each date call
health_score_repo.delete_for_user_date and then get_sleep_scores_for_date_range
for that date set (using sleep_score_service.get_sleep_scores_for_date_range),
and update callers that currently compute only the new date (use
self._local_sleep_date(old.start_datetime, old.zone_offset) and
self._local_sleep_date(new.start_datetime, new.zone_offset) to build a set of
dates and pass that set into the revised method so both the old and new local
dates are deleted and recomputed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6ec50a00-a91e-47a5-957b-b409e9a7a0ba

📥 Commits

Reviewing files that changed from the base of the PR and between fb8e435 and 1e45c1e.

📒 Files selected for processing (2)
  • backend/app/repositories/health_score_repository.py
  • backend/app/services/event_record_service.py

Comment thread backend/app/services/event_record_service.py Outdated
@railway-app
railway-app Bot temporarily deployed to Open Wearables / open-wearables-pr-884 April 16, 2026 11:43 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
backend/app/services/event_record_service.py (1)

123-131: Consider defensive check for empty zone_offset strings.

The check if zone_offset is not None passes for empty strings, which would cause an IndexError on zone_offset[0]. While input validation should prevent empty strings, a truthy check would be more defensive.

💡 Optional defensive improvement
     `@staticmethod`
     def _local_sleep_date(start_datetime: datetime, zone_offset: str | None) -> date:
         """Return the local calendar date of a sleep session start (mirrors SQL logic in fill task)."""
         dt = start_datetime if start_datetime.tzinfo is not None else start_datetime.replace(tzinfo=timezone.utc)
-        if zone_offset is not None:
+        if zone_offset:
             sign = 1 if zone_offset[0] == "+" else -1
             hours, minutes = int(zone_offset[1:3]), int(zone_offset[4:6])
             dt = dt.astimezone(timezone(timedelta(hours=sign * hours, minutes=sign * minutes)))
         return dt.date()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/services/event_record_service.py` around lines 123 - 131, In
_local_sleep_date, the current check "if zone_offset is not None" allows empty
strings and can cause IndexError when indexing zone_offset; change the guard to
verify zone_offset is truthy and properly formatted (e.g., ensure
len(zone_offset) >= 6 and zone_offset[0] in "+-") before parsing, and if invalid
either treat as no offset (keep UTC) or raise a clear ValueError; update the
parsing branch in _local_sleep_date to only run when the validated zone_offset
is present and well-formed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@backend/app/services/event_record_service.py`:
- Around line 123-131: In _local_sleep_date, the current check "if zone_offset
is not None" allows empty strings and can cause IndexError when indexing
zone_offset; change the guard to verify zone_offset is truthy and properly
formatted (e.g., ensure len(zone_offset) >= 6 and zone_offset[0] in "+-") before
parsing, and if invalid either treat as no offset (keep UTC) or raise a clear
ValueError; update the parsing branch in _local_sleep_date to only run when the
validated zone_offset is present and well-formed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: abb9cbd8-dad4-460c-88c9-ce7fed33fda5

📥 Commits

Reviewing files that changed from the base of the PR and between 1e45c1e and 4c1f202.

📒 Files selected for processing (1)
  • backend/app/services/event_record_service.py

@czajkub
czajkub force-pushed the sleep-merge-score-recalculation branch from 4c1f202 to dd9082b Compare April 23, 2026 11:13
@railway-app
railway-app Bot temporarily deployed to Open Wearables / open-wearables-pr-884 April 23, 2026 11:13 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
backend/app/services/event_record_service.py (1)

131-177: LGTM — correct transactional handling with one optional polish.

The helpers are well-structured: _local_sleep_date correctly snapshots the pre-mutation timezone, and _recompute_sleep_scores cleanly deletes-then-repopulates internal SLEEP scores within the same (uncommitted) transaction so sleep_score_service sees flushed session data. Early-returning on empty scores while leaving deletes in place is also the right call — a date with no sleep records should have no score. The delete path (provider="internal" string) and insert path (provider=ProviderName.INTERNAL enum) are consistent: ProviderName.INTERNAL serializes to "internal" in both directions.

Optional nit (not blocking): the manual zone_offset parsing at lines 134-138 could be simplified using datetime.strptime:

♻️ Optional refactor using strptime
-        dt = start_datetime if start_datetime.tzinfo is not None else start_datetime.replace(tzinfo=timezone.utc)
-        if zone_offset is not None:
-            sign = 1 if zone_offset[0] == "+" else -1
-            hours, minutes = int(zone_offset[1:3]), int(zone_offset[4:6])
-            dt = dt.astimezone(timezone(timedelta(hours=sign * hours, minutes=sign * minutes)))
-        return dt.date()
+        dt = start_datetime if start_datetime.tzinfo is not None else start_datetime.replace(tzinfo=timezone.utc)
+        if zone_offset is not None:
+            tz = datetime.strptime(zone_offset, "%z").tzinfo
+            dt = dt.astimezone(tz)
+        return dt.date()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/services/event_record_service.py` around lines 131 - 177, The
manual zone_offset parsing in _local_sleep_date can be simplified: instead of
slicing zone_offset and building a timedelta, parse the offset string with
datetime.strptime using the "%z" format to obtain a tzinfo and then call
dt.astimezone(that_tzinfo); update _local_sleep_date to use that parsed tzinfo
when zone_offset is not None and keep the existing dt.tzinfo handling unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@backend/app/services/event_record_service.py`:
- Around line 131-177: The manual zone_offset parsing in _local_sleep_date can
be simplified: instead of slicing zone_offset and building a timedelta, parse
the offset string with datetime.strptime using the "%z" format to obtain a
tzinfo and then call dt.astimezone(that_tzinfo); update _local_sleep_date to use
that parsed tzinfo when zone_offset is not None and keep the existing dt.tzinfo
handling unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 875d0948-3b5a-43ac-b8b6-ce07394156d6

📥 Commits

Reviewing files that changed from the base of the PR and between 4c1f202 and dd9082b.

📒 Files selected for processing (2)
  • backend/app/repositories/health_score_repository.py
  • backend/app/services/event_record_service.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/app/repositories/health_score_repository.py

@railway-app
railway-app Bot temporarily deployed to Open Wearables / open-wearables-pr-884 April 23, 2026 11:21 Destroyed

This branch was successfully deployed

No deployments
Open Wearables / open-wearables-pr-884 — 6ae221f4 Deployed Apr 23, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Tickets related to the backend (FastAPI, Celery, database) health-scores Issues related to Health Scores (like Sleep Score, Stress Level etc.)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants