Skip to content

feat(backend): add Whoop cycle endpoint for daily strain scores - #1124

Open
knowald wants to merge 3 commits into
the-momentum:mainfrom
knowald:feat/whoop-cycle-endpoint
Open

feat(backend): add Whoop cycle endpoint for daily strain scores#1124
knowald wants to merge 3 commits into
the-momentum:mainfrom
knowald:feat/whoop-cycle-endpoint

Conversation

@knowald

@knowald knowald commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Description

Adds /v2/cycle support to the Whoop provider, as requested in #1008. Whoop cycles carry the daily strain score (present even on days without workouts), which was previously never ingested - only per-workout strain was stored.

  • get_cycle_data fetches cycles with pagination from /v2/cycle
  • normalize_cycle maps a scored cycle to a HealthScoreCreate with category=STRAIN, qualifier="daily", the cycle's timezone_offset as zone_offset, and kilojoule / average_heart_rate / max_heart_rate as score components. The qualifier separates day-level cycle strain from the per-workout strain scores workouts.py already emits under the same (provider, category) - without it, consumers could not tell the two apart.
  • load_and_save_cycles is wired into load_and_save_all (new cycle_scores_synced result key)
  • Per the issue's suggestion to make the copied code reusable, the identical pagination loops in get_sleep_data and get_recovery_data were extracted into _get_paginated_records, which the new cycle fetch also uses
  • docs/providers/coverage.mdx: updated the stale "Strain scores available but not yet implemented" line in the Whoop accordion

In-progress cycles (no end yet) are skipped: their strain is provisional, and since health score inserts dedupe via on_conflict_do_nothing, saving a partial value would lock it in permanently. The completed cycle is picked up on the next sync.

No scope changes needed - read:cycles is already in whoop_default_scope.

Resolves #1008

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 (ruff + ty pass; prettier hook needs frontend node_modules, no frontend files touched)

Testing Instructions

Steps to test:

  1. cd backend && uv run pytest tests/providers/whoop/
  2. Connect a Whoop account and trigger a 247 sync.
  3. Check health scores for the user: daily strain entries (qualifier daily) should appear for each completed cycle, alongside recovery.

Expected behavior:

One strain health score per completed, scored cycle, with kilojoule and heart rate values as components and the cycle's timezone offset preserved. Unscored or in-progress cycles are skipped. Re-syncing the same window does not duplicate scores (covered by a test).

Additional Notes

  • This should also help with Bug: whoop app is showing workout but openwearables dashboard is not under strain data #1113 (strain visible in Whoop app but missing from the dashboard).
  • Merge-order heads-up: open PR refactor: move transaction boundaries from services/repos to callers #1092 (transaction boundaries to callers) rewrites every db.commit()/db.rollback() in this file to the savepoint/flush pattern. Whichever of the two merges second needs a small rebase; if refactor: move transaction boundaries from services/repos to callers #1092 lands first I will adapt the cycle block to begin_nested()/flush().
  • Known limitation, intentionally out of scope: a cycle that is still PENDING_SCORE when a sync window passes over it can be missed if it scores only after the next window starts. Padding the cycle fetch window behind last_synced_at (dedupe makes re-fetching idempotent) would close that gap; happy to do it here or in a follow-up if desired.
  • Related limitation: if Whoop re-scores an already-completed cycle (e.g. after late device data upload), the first saved strain stays in place because health score inserts use on_conflict_do_nothing. Fixing that means switching the dedupe to on_conflict_do_update, which changes shared HealthScoreRepository.bulk_create behavior for all providers - deliberately not done in this PR. The consumer-facing semantics of daily strain scores are documented in the qualifier field description and coverage.mdx.

Summary by CodeRabbit

  • New Features

    • Added Whoop cycle syncing and daily strain score support with paginated fetching and partial-result recovery on downstream errors.
  • Tests

    • Added tests for cycle normalization, pagination behavior, error recovery, persistence, and idempotent re-syncing to prevent duplicates.
  • Documentation

    • Updated provider docs and rate-limit guidance to reflect cycle/strain syncing and adjusted request estimates.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements Whoop cycle endpoint support for daily strain score synchronization. A shared pagination helper is introduced to eliminate duplication across sleep/recovery endpoints, the new cycle data flow fetches and normalizes cycles into health scores, and the orchestrator is extended to integrate cycle syncing with transaction safety.

Changes

Cycle Data Synchronization Implementation

Layer / File(s) Summary
Pagination Helper and Sleep/Recovery Refactor
backend/app/services/providers/whoop/data_247.py
A new _get_paginated_records helper method centralizes pagination logic for Whoop v2 collection endpoints. get_sleep_data and get_recovery_data are refactored to delegate to this helper instead of managing their own pagination loops. The helper records raw API payloads, aggregates records from multiple pages, logs context on exceptions, and returns partial results if any were collected.
Cycle Data Fetching, Normalization, and Persistence
backend/app/services/providers/whoop/data_247.py
New get_cycle_data method uses the pagination helper to fetch /v2/cycle records. New normalize_cycle method creates a HealthScoreCreate entry only for SCORED cycles with an end time and usable start/strain values, mapping strain to health score category and numeric value. New load_and_save_cycles method normalizes all fetched cycles, bulk-creates their corresponding health scores, commits on success, and returns the count created.
Cycle Syncing Orchestration
backend/app/services/providers/whoop/data_247.py
load_and_save_all is extended with a cycle_scores_synced counter and a new try/catch block that calls load_and_save_cycles, rolls back on failure with error logging, and increments the counter on success. Module and method docstrings are updated to document cycle data coverage; constructor return type annotated.
Test Fixtures and Normalization Validation
backend/tests/providers/whoop/test_whoop_247.py
Test suite adds whoop_247 and sample_cycle fixtures to support test execution. Six normalization tests validate the normalize_cycle method: successful conversion of a scored/completed cycle into a HealthScore with expected category, qualifier, zone offset, and per-component values; and skip conditions for unscored state, missing end time, missing strain, null score, and malformed start timestamp.
API Pagination and Persistence Tests
backend/tests/providers/whoop/test_whoop_247.py
Tests validate get_cycle_data pagination across multiple pages with nextToken handling. Database integration tests confirm load_and_save_cycles creates expected HealthScore records and that re-syncing the same time window does not duplicate records via uniqueness constraints. Resilience tests verify partial results are returned when errors occur after the first page, while exceptions on the first page are propagated.
Module and Provider Documentation
backend/app/services/providers/whoop/data_247.py, docs/providers/coverage.mdx, docs/providers/whoop-api-integration.mdx, backend/app/schemas/model_crud/activities/health_score.py
Module docstring now includes "cycle" alongside sleep/recovery/activity endpoints. Whoop provider quirks documentation is updated to reflect strain scores are now implemented, specifying daily strain is synced from the cycle endpoint and per-workout strain is synced via workouts. Rate-limit integration docs updated to account for the additional cycles API calls. HealthScore qualifier Field description updated with Whoop semantics.

Sequence Diagram

sequenceDiagram
  participant load_and_save_all as load_and_save_all()
  participant load_and_save_cycles as load_and_save_cycles()
  participant get_cycle_data as get_cycle_data()
  participant _get_paginated_records as _get_paginated_records()
  participant normalize_cycle as normalize_cycle()
  participant db as Database

  load_and_save_all->>load_and_save_cycles: call with user_id, start_time, end_time
  load_and_save_cycles->>get_cycle_data: request cycles for range
  get_cycle_data->>_get_paginated_records: paginate /v2/cycle endpoint with params
  _get_paginated_records-->>get_cycle_data: return aggregated raw pages
  get_cycle_data-->>load_and_save_cycles: return raw cycle list
  loop per raw cycle
    load_and_save_cycles->>normalize_cycle: convert raw cycle -> HealthScoreCreate?
    normalize_cycle-->>load_and_save_cycles: HealthScoreCreate or None
  end
  load_and_save_cycles->>db: bulk_create HealthScore rows
  load_and_save_cycles->>db: commit transaction
  load_and_save_cycles-->>load_and_save_all: return created_count
  load_and_save_all->>load_and_save_all: set results["cycle_scores_synced"]
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly Related PRs

Suggested Labels

backend, whoop, feature, health scores

Suggested Reviewers

  • bartmichalak
  • KaliszS
  • czajkub

Poem

🐰 A cycle syncs beneath the moon,
Pages fetch and not a byte too soon,
Strain becomes a score each day,
Tests ensure we don’t misplay.
From API to DB — hop, hooray!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(backend): add Whoop cycle endpoint for daily strain scores' directly and clearly summarizes the main change: adding the Whoop cycle endpoint to support daily strain score ingestion.
Linked Issues check ✅ Passed The PR fully implements issue #1008: adds /v2/cycle endpoint support to data_247.py, implements cycle data ingestion for daily strain scores, extracts reusable pagination logic via _get_paginated_records, and integrates cycle synchronization into load_and_save_all.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the linked issue objectives: cycle endpoint implementation, pagination logic extraction, daily strain score normalization, and documentation updates. No extraneous modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 96.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/app/services/providers/whoop/data_247.py (1)

35-40: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add return type annotation to __init__.

__init__ is missing -> None, which violates the repository’s required function typing rules.

Suggested patch
     def __init__(
         self,
         provider_name: str,
         api_base_url: str,
         oauth: BaseOAuthTemplate,
-    ):
+    ) -> None:
         super().__init__(provider_name, api_base_url, oauth)
         self.event_record_repo = EventRecordRepository(EventRecord)
         self.data_source_repo = DataSourceRepository(DataSource)
         self.connection_repo = UserConnectionRepository()

As per coding guidelines, **/*.py: "All Python functions must have type annotations."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/providers/whoop/data_247.py` around lines 35 - 40, The
__init__ method in the class defined in data_247.py is missing a return type
annotation; update the constructor signature for the __init__ method (the one
taking provider_name, api_base_url, oauth / BaseOAuthTemplate) to include the
explicit return type -> None so it conforms to the repository’s function typing
rules.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/app/services/providers/whoop/data_247.py`:
- Around line 35-40: The __init__ method in the class defined in data_247.py is
missing a return type annotation; update the constructor signature for the
__init__ method (the one taking provider_name, api_base_url, oauth /
BaseOAuthTemplate) to include the explicit return type -> None so it conforms to
the repository’s function typing rules.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 68e170a4-c845-4c58-85be-1563fe0b34a0

📥 Commits

Reviewing files that changed from the base of the PR and between 85bb567 and 90be798.

📒 Files selected for processing (3)
  • backend/app/services/providers/whoop/data_247.py
  • backend/tests/providers/whoop/__init__.py
  • backend/tests/providers/whoop/test_whoop_247_data.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/app/services/providers/whoop/data_247.py (2)

467-470: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the default sync window UTC-aware.

datetime.now() creates naive timestamps here. _get_paginated_records() later calls .astimezone(timezone.utc), so on any worker that is not already running in UTC the default 30-day window shifts by the host offset and can miss or re-fetch boundary records. Use datetime.now(timezone.utc) for both defaults.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/providers/whoop/data_247.py` around lines 467 - 470, The
default 30-day window uses naive datetimes; change the defaults for start_time
and end_time to timezone-aware UTC datetimes (use datetime.now(timezone.utc)) so
they match the expectations of _get_paginated_records() which calls
.astimezone(timezone.utc); update the start_time and end_time assignments
accordingly and ensure timezone is imported/available from datetime in this
module.

985-1011: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't report deduped cycle rows as *_synced.

On a replayed window, bulk_create can legitimately no-op because the HealthScore row already exists, but this method still returns len(health_scores). That makes the new cycle_scores_synced field overstate actual writes and turns an idempotent resync into a misleading success count. Either return the inserted-row count from the persistence layer or rename this metric to processed/normalized so downstream sync reporting stays accurate.

Based on learnings: external-provider HealthScore dedupe is keyed by (user_id, provider, category, recorded_at), so repeated windows can no-op at the DB even when this method returns the full normalized count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/app/services/providers/whoop/data_247.py` around lines 985 - 1011,
The method currently returns len(health_scores) which counts normalized items
even if bulk_create dedupes existing rows; update load_and_save_cycles to use
the actual inserted-row count from the persistence layer instead of the length
of the normalized list: change the call to health_score_service.bulk_create(db,
health_scores) to return an int (e.g. inserted_count) from the service (or have
the service return that from the underlying repository upsert/insert_many),
assign and use that returned value (commit after successful insert) and return
inserted_count; reference the load_and_save_cycles function and
health_score_service.bulk_create/underlying repository insert/upsert methods
when making this change so metrics reflect actual DB writes rather than
processed/normalized items.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@backend/app/services/providers/whoop/data_247.py`:
- Around line 467-470: The default 30-day window uses naive datetimes; change
the defaults for start_time and end_time to timezone-aware UTC datetimes (use
datetime.now(timezone.utc)) so they match the expectations of
_get_paginated_records() which calls .astimezone(timezone.utc); update the
start_time and end_time assignments accordingly and ensure timezone is
imported/available from datetime in this module.
- Around line 985-1011: The method currently returns len(health_scores) which
counts normalized items even if bulk_create dedupes existing rows; update
load_and_save_cycles to use the actual inserted-row count from the persistence
layer instead of the length of the normalized list: change the call to
health_score_service.bulk_create(db, health_scores) to return an int (e.g.
inserted_count) from the service (or have the service return that from the
underlying repository upsert/insert_many), assign and use that returned value
(commit after successful insert) and return inserted_count; reference the
load_and_save_cycles function and health_score_service.bulk_create/underlying
repository insert/upsert methods when making this change so metrics reflect
actual DB writes rather than processed/normalized items.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c3024946-936c-4c96-b2c1-0c854055a67f

📥 Commits

Reviewing files that changed from the base of the PR and between 3eb5e5c and 6f43d46.

📒 Files selected for processing (4)
  • backend/app/schemas/model_crud/activities/health_score.py
  • backend/app/services/providers/whoop/data_247.py
  • docs/providers/coverage.mdx
  • docs/providers/whoop-api-integration.mdx
✅ Files skipped from review due to trivial changes (2)
  • docs/providers/coverage.mdx
  • backend/app/schemas/model_crud/activities/health_score.py

@knowald

knowald commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Addressing the three CodeRabbit findings from the review body:

  • __init__ missing -> None: already fixed in this PR's diff.
  • Naive datetime.now() defaults in load_and_save_all (lines 467-470): that block predates this PR and is untouched by it. On closer inspection the behavior claim does not hold either - the only consumer is .astimezone(timezone.utc) formatting, which interprets naive as system-local, exactly what datetime.now() produces, so the window is correct on any host timezone. Still worth making aware-from-construction as hygiene; shipped separately as refactor(backend): construct default Whoop sync window as UTC-aware datetimes #1157 with details.
  • cycle_scores_synced counting deduped rows: intentional and documented in the method docstring ("rows already present ... are still counted here"). Every other counter in this file has the same semantics (load_and_save_activity and body measurement return len(samples_to_create)), so changing only the cycle field would make the sync report internally inconsistent. If inserted-row counts are wanted, that is a file-wide (or provider-wide) change better tracked as its own issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cycle endpoint support for Whoop

2 participants