diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 51489db66..7eb91358e 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -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`). */ diff --git a/src/lib/whoop/mapping.md b/src/lib/whoop/mapping.md index 0e4026743..bd9648bdd 100644 --- a/src/lib/whoop/mapping.md +++ b/src/lib/whoop/mapping.md @@ -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`