Skip to content

feat(load): WHOOP API v2 loader with OAuth2 - #31

Merged
ErikBjare merged 2 commits into
masterfrom
feat/whoop-api
Jul 10, 2026
Merged

feat(load): WHOOP API v2 loader with OAuth2#31
ErikBjare merged 2 commits into
masterfrom
feat/whoop-api

Conversation

@ErikBjare

Copy link
Copy Markdown
Owner

Why

Whoop physiology in the QS export has been frozen at 2026-05-13 (ErikBjare/alice#65). Root cause: config.toml points at a static CSV export dir downloaded on 05-13 — there was never any OAuth to expire. This adds a live WHOOP API v2 integration so the data keeps advancing without manual re-downloads.

What

  • load/whoop_api.py — new module:
    • OAuth2 authorization-code flow (python -m quantifiedme.load.whoop_api auth): one-shot localhost redirect server, browser consent, token persisted to platformdirs with 0600 perms.
    • Rotation-safe refresh: WHOOP invalidates the old refresh token on every refresh, so the rotated token is written to disk immediately.
    • Paginated fetchers (nextToken, limit 25, 429 backoff) for cycles / recoveries / sleeps / workouts.
    • Incremental on-disk JSON cache keyed by record id, re-fetching a 14-day overlap window to pick up late score revisions — daily export runs stay fast.
    • Mappers producing the same DataFrame schemas as the CSV loaders (verified column-for-column against _load_sleep_standard / _load_cycles_standard / _load_workouts_standard), incl. wake-date indexing convention and kJ→kcal conversion.
    • CLI: auth / status / fetch.
  • load/whoop.py — public loaders dispatch to the API when a token exists on the machine; file exports remain the fallback. API errors are deliberately not swallowed into a file fallback — silent staleness is exactly the failure mode this fixes. Journal loader stays file-based (API doesn't expose journal entries).
  • Credentials from WHOOP_CLIENT_ID/WHOOP_CLIENT_SECRET env vars or .env.whoop at repo root (now gitignored via .env.*).
  • requests promoted to a direct dependency.

Tests

25 new tests (fixtures match the official v2 response schemas): mapping incl. tz-offset wake-date edge cases, nap/unscored exclusion, refresh-token rotation persistence, pagination param casing (next_token in response vs nextToken in query), incremental cache upsert, and dispatch. Existing whoop CSV tests pinned to file dispatch so a real token on a dev machine doesn't flip them. All 50 whoop tests pass.

…reeze)

Adds load/whoop_api.py: OAuth2 authorization-code flow (rotating refresh
tokens persisted immediately), incremental on-disk record cache, and
DataFrame mappers matching the CSV-loader schemas. whoop.py public loaders
dispatch to the API when authorized, so all_df/sleep pick up live data
with no config change. File-export loaders remain as fallback; journal
stays file-based (not exposed by the API).

Context: Whoop columns in the QS export were frozen at the date of the
last manual CSV download (see ErikBjare/alice#65).
@ErikBjare

Copy link
Copy Markdown
Owner Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds live WHOOP API loading alongside the existing export loaders. The main changes are:

  • OAuth2 authorization and token refresh support.
  • Paginated WHOOP v2 collection fetching with local JSON caches.
  • API mappers that produce the existing sleep, cycle, and workout DataFrame shapes.
  • Public WHOOP loaders that prefer the API when a token exists.
  • Docs, config comments, gitignore, dependency, and tests for the new path.

Confidence Score: 4/5

The WHOOP API mapper needs fixes before merging live data from it.

  • Zero-valued energy scores can be exported as missing values.
  • Empty sleep or recovery API results can lose the expected DataFrame columns.
  • The OAuth, pagination, and dispatch structure otherwise matches the intended flow.

src/quantifiedme/load/whoop_api.py

Important Files Changed

Filename Overview
src/quantifiedme/load/whoop_api.py Adds the WHOOP API client, cache, CLI, and mappers; the mapper needs fixes for missing and zero-valued fields.
src/quantifiedme/load/whoop.py Routes public sleep, cycle, and workout loaders to the API when authorization exists, with file exports as the non-authorized path.
tests/test_load_whoop_api.py Adds coverage for OAuth refresh, pagination, caching, dispatch, and common mapper cases.
tests/test_load_whoop.py Pins existing export-loader tests to the file path so local API tokens do not change test behavior.
pyproject.toml Adds requests as a direct dependency for the API loader.
README.md Mentions live WHOOP API support in the supported data-source list.
config.example.toml Documents that the WHOOP config entry is for CSV/GDPR exports while the API path needs authorization.
.gitignore Ignores .env.* files, including the new .env.whoop credential file.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[whoop.load_sleep_df / load_cycles_df / load_workouts_df] --> B{OAuth token file exists?}
  B -- yes --> C[WHOOP API loader]
  B -- no --> D[CSV/GDPR export loader]
  C --> E[Fetch paginated API collections]
  E --> F[Update JSON cache]
  F --> G[Map records to existing DataFrame schemas]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  A[whoop.load_sleep_df / load_cycles_df / load_workouts_df] --> B{OAuth token file exists?}
  B -- yes --> C[WHOOP API loader]
  B -- no --> D[CSV/GDPR export loader]
  C --> E[Fetch paginated API collections]
  E --> F[Update JSON cache]
  F --> G[Map records to existing DataFrame schemas]
Loading

Reviews (1): Last reviewed commit: "feat(load): add WHOOP API v2 loader with..." | Re-trigger Greptile

Comment thread src/quantifiedme/load/whoop_api.py Outdated
Comment on lines +372 to +375
"debt": (score.get("sleep_needed") or {}).get(
"need_from_sleep_debt_milli", 0
)
/ 60_000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Missing Debt Becomes Zero

When WHOOP omits sleep_needed or need_from_sleep_debt_milli from a scored sleep, this maps the missing value to 0 minutes. That makes incomplete API data look like a real zero sleep debt, while CSV blanks would stay missing and downstream summaries can underreport debt.

Suggested change
"debt": (score.get("sleep_needed") or {}).get(
"need_from_sleep_debt_milli", 0
)
/ 60_000,
"debt": (
(score.get("sleep_needed") or {}).get("need_from_sleep_debt_milli")
)
/ 60_000
if (score.get("sleep_needed") or {}).get("need_from_sleep_debt_milli") is not None
else None,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fd8c930

Comment on lines +422 to +424
"strain": cycle_score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule else None,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zero Energy Becomes Missing

When WHOOP returns a valid cycle score with kilojoule: 0, this truthiness check exports None instead of 0.0 kcal. Callers can no longer tell a real zero-energy day from a missing energy score.

Suggested change
"strain": cycle_score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule else None,
}
"strain": cycle_score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule is not None else None,
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fd8c930

Comment on lines +443 to +445
"strain": score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule else None,
"max_hr": score.get("max_heart_rate"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zero Workout Energy Disappears

When WHOOP returns a workout score with kilojoule: 0, this truthiness check maps it to None. The workout DataFrame then reports the energy field as missing instead of preserving the real zero value.

Suggested change
"strain": score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule else None,
"max_hr": score.get("max_heart_rate"),
"strain": score.get("strain"),
"energy_kcal": kilojoule * KCAL_PER_KILOJOULE if kilojoule is not None else None,
"max_hr": score.get("max_heart_rate"),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fd8c930

Comment thread src/quantifiedme/load/whoop_api.py Outdated
Comment on lines +466 to +467
if df.empty:
return df

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Empty Results Lose Schema

When all API sleep or recovery records are filtered out, _to_daily_df returns a DataFrame with no columns. Existing CSV loaders still expose the expected columns on empty results, so downstream code can silently lose score, duration, or recovery columns instead of receiving an empty compatible WHOOP frame.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fd8c930

@greptile-apps

greptile-apps Bot commented Jul 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds live WHOOP API loading alongside the existing export loaders. The main changes are:

  • OAuth2 auth, token refresh, and local token storage for WHOOP API v2.
  • Paginated API fetching with incremental JSON caching.
  • API-to-dataframe mappers matching the CSV loader shapes.
  • Automatic WHOOP loader dispatch when an API token exists.
  • Tests and docs for the new API path.

Confidence Score: 4/5

The WHOOP API mapper needs fixes before merging.

  • Negative fractional timezone offsets can put rows on the wrong day.
  • Zero energy values can be exported as missing values.
  • Empty valid API results can lose the CSV-compatible dataframe columns.

src/quantifiedme/load/whoop_api.py

Important Files Changed

Filename Overview
src/quantifiedme/load/whoop_api.py Adds the new WHOOP API client, cache, mappers, and CLI; the mapper edge cases need fixes.
src/quantifiedme/load/whoop.py Routes public sleep, cycle, and workout loaders to the API when an auth token exists.
tests/test_load_whoop_api.py Adds coverage for auth refresh, pagination, caching, mapping, and dispatch.
tests/test_load_whoop.py Pins existing export-loader tests to the file path so local API auth does not change them.
pyproject.toml Adds requests as a direct dependency.
.gitignore Ignores .env.* credential files.
README.md Mentions live WHOOP API support.
config.example.toml Documents the export directory setting and API precedence.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[whoop.load_* caller] --> B{API token file exists}
  B -- no --> C[CSV/GDPR export loader]
  B -- yes --> D[whoop_api loader]
  D --> E[OAuth access token]
  D --> F[Incremental cache]
  F --> G[WHOOP API pagination]
  G --> H[Record mappers]
  H --> I[Pandas dataframes]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
  A[whoop.load_* caller] --> B{API token file exists}
  B -- no --> C[CSV/GDPR export loader]
  B -- yes --> D[whoop_api loader]
  D --> E[OAuth access token]
  D --> F[Incremental cache]
  F --> G[WHOOP API pagination]
  G --> H[Record mappers]
  H --> I[Pandas dataframes]
Loading

Reviews (2): Last reviewed commit: "feat(load): add WHOOP API v2 loader with..." | Re-trigger Greptile

Comment on lines +340 to +342
else pd.Timedelta(
hours=int(tz_offset[:3]), minutes=int(tz_offset[0] + tz_offset[4:6])
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Negative Offset Shifts Dates

When WHOOP returns a negative fractional timezone offset such as -03:30, this builds -3h + 30m instead of -3h - 30m. Sleep and recovery rows can then be indexed under the wrong local wake date.

Suggested change
else pd.Timedelta(
hours=int(tz_offset[:3]), minutes=int(tz_offset[0] + tz_offset[4:6])
)
else pd.Timedelta(
minutes=(1 if tz_offset[0] == "+" else -1)
* (int(tz_offset[1:3]) * 60 + int(tz_offset[4:6]))
)

score = r["score"]

sleep = sleep_by_id.get(r.get("sleep_id"))
if sleep is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zero Energy Becomes Missing

When a scored cycle has kilojoule: 0, this falsy check exports None instead of 0.0 kcal. Downstream summaries then see a missing value rather than a real zero-energy day.

"start": start,
"end": end,
"duration": end - start,
"activity": r.get("sport_name"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zero Workout Energy Disappears

When a workout score contains kilojoule: 0, this writes None instead of 0.0 kcal. Callers lose the difference between an actual zero-energy workout and an absent energy score.

Comment on lines +480 to +481
return _sleeps_to_df(fetch_collection("sleeps"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Empty Daily Frames Lose Schema

When every fetched sleep or recovery record is filtered out as a nap or unscored, _to_daily_df([]) returns a dataframe with no columns. That breaks the promised CSV-compatible schema, so callers expecting fields like score, duration, or recovery can fail on an otherwise valid empty API result. Should the daily mappers pass their expected column lists into _to_daily_df, or should each mapper return its own empty dataframe before calling this helper?

…hema

Address Greptile review: kilojoule=0 no longer maps to None (real
zero-energy days), missing sleep_needed no longer fabricates zero debt,
and empty daily frames keep the CSV-loader column schema.
@ErikBjare
ErikBjare merged commit f6c395c into master Jul 10, 2026
5 of 6 checks passed
@ErikBjare
ErikBjare deleted the feat/whoop-api branch July 10, 2026 17:11
ErikBjare added a commit that referenced this pull request Jul 10, 2026
PR #31 was merged while the typecheck job was still running (the CI watch
raced and bound to the previous commit's checks) and broke typecheck on
master: dict spreads over unannotated module-level fixtures infer object,
and Index.tz needs a DatetimeIndex narrow.
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.

1 participant