feat(whoop): map cycle average/max HR and sleep disturbance count

mapCycle now emits AVERAGE_HEART_RATE and MAX_HEART_RATE from the cycle
score's average_heart_rate / max_heart_rate — both were already on the typed
WhoopCycle interface but dropped on the floor. mapSleep now reads
stage_summary.disturbance_count (added to the WhoopSleep interface) and emits
SLEEP_DISTURBANCE_COUNT. All three guard nulls / unscored records and carry
distinct field tags (avg_hr / max_hr / disturbances) so they don't collide
with the existing strain / energy / stage rows under the shared resource
externalId. Update mapping.md to document the three and note the cycle HR
pair were previously fetched-but-dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-05 14:46:05 +02:00
1 parent ef188e0503
commit dabedd2edc
2 files changed
+47 -4

No files matched your search

+41 -3
View File
@@ -190,6 +190,9 @@ export interface WhoopSleep {
total_light_sleep_time_milli: number;
total_slow_wave_sleep_time_milli: number;
total_rem_sleep_time_milli: number;
// v1.12.8 — per-night sleep disturbance tally. Optional: older / still-
// scoring records may omit it.
disturbance_count?: number;
};
sleep_needed: {
baseline_milli: number;
@@ -581,18 +584,29 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
});
}
if (typeof stages.disturbance_count === "number") {
out.push({
type: "SLEEP_DISTURBANCE_COUNT",
value: stages.disturbance_count,
unit: "count",
measuredAt,
fieldTag: "disturbances",
});
}
return out;
}
/**
* Map one WHOOP cycle (day) record: `DAY_STRAIN` (distinct from the COMPUTED
* `STRAIN_SCORE`) and `ENERGY_EXPENDITURE_KJ` (kept in native kJ). Energy is
* NOT converted to kcal here — that conversion is for the workout path only.
* `STRAIN_SCORE`), `ENERGY_EXPENDITURE_KJ` (kept in native kJ), and the day's
* whole-cycle `AVERAGE_HEART_RATE` / `MAX_HEART_RATE`. Energy is NOT converted
* to kcal here — that conversion is for the workout path only.
*/
export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
if (!c.score) return [];
const measuredAt = new Date(c.start);
return [
const out: MappedMeasurement[] = [
{
type: "DAY_STRAIN",
value: round2(c.score.strain),
@@ -608,6 +622,30 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
fieldTag: "energy_kj",
},
];
// v1.12.8 — the cycle score's average / max heart rate were fetched but
// dropped before this release. Distinct fieldTags keep them from colliding
// with the strain / energy rows under the shared cycle `externalId`.
if (typeof c.score.average_heart_rate === "number") {
out.push({
type: "AVERAGE_HEART_RATE",
value: Math.round(c.score.average_heart_rate),
unit: "bpm",
measuredAt,
fieldTag: "avg_hr",
});
}
if (typeof c.score.max_heart_rate === "number") {
out.push({
type: "MAX_HEART_RATE",
value: Math.round(c.score.max_heart_rate),
unit: "bpm",
measuredAt,
fieldTag: "max_hr",
});
}
return out;
}
/** Metres → centimetres (WHOOP `height_meter` → `User.heightCm`). */
+6 -1
View File
@@ -35,9 +35,12 @@ A recovery record with `score === null` (WHOOP still scoring) maps to nothing.
| `sleep_efficiency_percentage` | `SLEEP_EFFICIENCY` | % | `sleep_eff` | — |
| `sleep_consistency_percentage` | `SLEEP_CONSISTENCY` | % | `sleep_consistency` | — |
| `respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | — |
| `stage_summary.disturbance_count` | `SLEEP_DISTURBANCE_COUNT` | count | `disturbances` | — |
Stage durations are ms→minutes (`÷ 60000`). One row per stage per night
(same pattern as Apple Health). Percentage / respiratory fields are optional.
(same pattern as Apple Health). Percentage / respiratory / disturbance-count
fields are optional. `disturbance_count` (v1.12.8) is a per-night tally — a
plain integer, stored as-is.
## Cycle (`/v2/cycle`) — record `start` is `measuredAt`
@@ -45,6 +48,8 @@ Stage durations are ms→minutes (`÷ 60000`). One row per stage per night
|---|---|---|---|---|
| `cycle.score.strain` | `DAY_STRAIN` | score | `day_strain` | 0–21 WHOOP scale. Distinct from the COMPUTED `STRAIN_SCORE` (0–100 TRIMP proxy). |
| `cycle.score.kilojoule` | `ENERGY_EXPENDITURE_KJ` | kJ | `energy_kj` | Native kJ — NOT converted to kcal (the workout path converts). |
| `cycle.score.average_heart_rate` | `AVERAGE_HEART_RATE` | bpm | `avg_hr` | v1.12.8 — the day's whole-cycle average HR. Fetched but dropped before v1.12.8; now mapped. Distinct from spot `PULSE` / `RESTING_HEART_RATE` / `WALKING_HEART_RATE_AVERAGE`. |
| `cycle.score.max_heart_rate` | `MAX_HEART_RATE` | bpm | `max_hr` | v1.12.8 — the day's whole-cycle peak HR. Fetched but dropped before v1.12.8; now mapped. |
## Workout (`/v2/activity/workout`) — into `Workout`, not `Measurement`