feat(measurements): add WHOOP source, native score types, and source-priority ladders

Add the WHOOP integration data layer (migration 0111): seven new
MeasurementType values (HRV_RMSSD, DAY_STRAIN, WORKOUT_STRAIN,
SLEEP_PERFORMANCE, SLEEP_EFFICIENCY, SLEEP_CONSISTENCY, SLEEP_NEED,
ENERGY_EXPENDITURE_KJ), a WHOOP MeasurementSource, the WhoopConnection
and WhoopOAuthState models, and the per-user encrypted BYO-key columns
on User. Day-strain takes DAY_STRAIN (not the COMPUTED STRAIN_SCORE) and
RMSSD takes HRV_RMSSD (not the SDNN HEART_RATE_VARIABILITY) so a native
value never shares a bucket with a derived proxy.

Carry the new types through the completeness walls (units, value ranges,
chart tokens, PR direction, categories, list-meta colours/icons/labels,
HealthKit-mapping exclusions) and feed the cross-source picker: recovery
ladder (WHOOP native above the COMPUTED proxy), WHOOP inserted into the
overlapping vital ladders and the workout ladder, plus skinTemperature
and respiratoryRate metric keys.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 00:49:31 +02:00
1 parent 747a62516a
commit 6db3bef413
21 files changed
+649 -31

No files matched your search

@@ -100,6 +100,15 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
RECOVERY_SCORE: "measurements.typeRecoveryScore",
STRESS_SCORE: "measurements.typeStressScore",
STRAIN_SCORE: "measurements.typeStrainScore",
// ── v1.11.0 — WHOOP-native score classes ──
HRV_RMSSD: "measurements.typeHrvRmssd",
DAY_STRAIN: "measurements.typeDayStrain",
WORKOUT_STRAIN: "measurements.typeWorkoutStrain",
SLEEP_PERFORMANCE: "measurements.typeSleepPerformance",
SLEEP_EFFICIENCY: "measurements.typeSleepEfficiency",
SLEEP_CONSISTENCY: "measurements.typeSleepConsistency",
SLEEP_NEED: "measurements.typeSleepNeed",
ENERGY_EXPENDITURE_KJ: "measurements.typeEnergyExpenditureKj",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -191,6 +200,19 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
RECOVERY_SCORE: Gauge,
STRESS_SCORE: Gauge,
STRAIN_SCORE: Gauge,
// ── v1.11.0 — WHOOP-native score classes ──
// Gauge reads as a composite "index / score" dial for the strain +
// sleep-quality composites; the sleep-need recommendation borrows Moon
// (sleep family), RMSSD borrows HeartPulse (cardiac), energy borrows
// Flame (energy family, like ACTIVE_ENERGY_BURNED).
HRV_RMSSD: HeartPulse,
DAY_STRAIN: Gauge,
WORKOUT_STRAIN: Gauge,
SLEEP_PERFORMANCE: Moon,
SLEEP_EFFICIENCY: Moon,
SLEEP_CONSISTENCY: Moon,
SLEEP_NEED: Moon,
ENERGY_EXPENDITURE_KJ: Flame,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -274,4 +296,16 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
RECOVERY_SCORE: "bg-chart-1/20 text-chart-1",
STRESS_SCORE: "bg-chart-1/20 text-chart-1",
STRAIN_SCORE: "bg-chart-1/20 text-chart-1",
// ── v1.11.0 — WHOOP-native score classes ──
// chart-1 (Dracula purple) for the strain composites (same group as the
// WX-C scores); chart-2 (sleep/activity family) for the sleep-quality set
// and energy; chart-5 (cardio/pulse family) for RMSSD HRV.
HRV_RMSSD: "bg-chart-5/20 text-chart-5",
DAY_STRAIN: "bg-chart-1/20 text-chart-1",
WORKOUT_STRAIN: "bg-chart-1/20 text-chart-1",
SLEEP_PERFORMANCE: "bg-chart-2/20 text-chart-2",
SLEEP_EFFICIENCY: "bg-chart-2/20 text-chart-2",
SLEEP_CONSISTENCY: "bg-chart-2/20 text-chart-2",
SLEEP_NEED: "bg-chart-2/20 text-chart-2",
ENERGY_EXPENDITURE_KJ: "bg-chart-4/20 text-chart-4",
};
@@ -79,6 +79,10 @@ const METRIC_LABEL_KEYS: Record<SourcePriorityMetricKey, string> = {
hrv: "settings.sections.sources.metrics.hrv",
restingHeartRate: "settings.sections.sources.metrics.restingHeartRate",
vo2Max: "settings.sections.sources.metrics.vo2Max",
// v1.11.0 — WHOOP-overlapping metric classes + native-vs-derived recovery.
skinTemperature: "settings.sections.sources.metrics.skinTemperature",
respiratoryRate: "settings.sections.sources.metrics.respiratoryRate",
recovery: "settings.sections.sources.metrics.recovery",
};
const SOURCE_LABEL_KEYS: Record<string, string> = {
@@ -86,6 +90,10 @@ const SOURCE_LABEL_KEYS: Record<string, string> = {
APPLE_HEALTH: "settings.sections.sources.sourceLabels.APPLE_HEALTH",
MANUAL: "settings.sections.sources.sourceLabels.MANUAL",
IMPORT: "settings.sections.sources.sourceLabels.IMPORT",
// v1.11.0 — WHOOP native source + the COMPUTED proxy (surfaces in the
// `recovery` ladder for native-vs-derived ordering).
WHOOP: "settings.sections.sources.sourceLabels.WHOOP",
COMPUTED: "settings.sections.sources.sourceLabels.COMPUTED",
};
const DEVICE_TYPE_LABEL_KEYS: Record<DeviceType, string> = {
@@ -258,6 +258,8 @@ describe("i18n locale file integrity", () => {
"medications.wizard.steps.step3.unit.mg",
"medications.wizard.steps.step3.unit.ml",
"medications.wizard.steps.step3.unit.g",
// v1.11.0 — "WHOOP" is a brand name, identical across every locale.
"settings.sections.sources.sourceLabels.WHOOP",
]);
it.each(ALL_LOCALES)("$locale locale has no empty values", ({ path }) => {
@@ -77,10 +77,19 @@ const EXPECTED_TYPES = [
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
// ── v1.11.0 — WHOOP-native score classes ──
"HRV_RMSSD",
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
"SLEEP_EFFICIENCY",
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 53 canonical measurement types", () => {
it("exposes the 60 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -180,6 +189,19 @@ describe("measurementTypeEnum coverage", () => {
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
// v1.11.0 — WHOOP-native score classes. Device-derived strain / recovery
// / sleep-quality composites and a kJ energy total, not measured clinical
// vitals. They surface on their own Insights cluster with a "descriptive,
// not clinical" disclaimer and never belong in the clinical vitals PDF.
// See doctor-report-pdf-core.ts for the matching exclusion rationale.
"HRV_RMSSD",
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
"SLEEP_EFFICIENCY",
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+22
View File
@@ -97,6 +97,18 @@ export const ALLOWED_CHART_TOKENS = [
"metric:RECOVERY_SCORE",
"metric:STRESS_SCORE",
"metric:STRAIN_SCORE",
// v1.11.0 — WHOOP-native score classes. Continuous daily series, so each
// carries a `metric:<TYPE>` token and renders through the generic chart
// renderer (unlike the categorical EVENT classes). Same posture as the
// WX-C scores above: the LLM can reference them in prose.
"metric:HRV_RMSSD",
"metric:DAY_STRAIN",
"metric:WORKOUT_STRAIN",
"metric:SLEEP_PERFORMANCE",
"metric:SLEEP_EFFICIENCY",
"metric:SLEEP_CONSISTENCY",
"metric:SLEEP_NEED",
"metric:ENERGY_EXPENDITURE_KJ",
] as const;
export type ChartToken = (typeof ALLOWED_CHART_TOKENS)[number];
@@ -197,6 +209,16 @@ const ORPHAN_ENUMS = [
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
// v1.11.0 — WHOOP-native score classes, same shape: strip the bare enum
// name if the model drops it into prose.
"HRV_RMSSD",
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
"SLEEP_EFFICIENCY",
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
] as const;
// `\b` boundaries keep ordinary English prose untouched — "weight"
@@ -39,6 +39,19 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
// v1.11.0 — WHOOP-native score classes. These ingest server-side from the
// WHOOP API (source = WHOOP), never from HealthKit; Apple ships no
// identifier for day/workout strain, the WHOOP sleep-quality indices, sleep
// need, RMSSD HRV (Apple ships only the SDNN variant), or kJ energy. They
// have no HK mapping by design.
"HRV_RMSSD",
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
"SLEEP_EFFICIENCY",
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
+15
View File
@@ -165,6 +165,21 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
["RECOVERY_SCORE", "scores"],
["STRESS_SCORE", "scores"],
["STRAIN_SCORE", "scores"],
// ── v1.11.0 — WHOOP-native score classes ──
// Day/workout strain are composite indices alongside the other scores.
["DAY_STRAIN", "scores"],
["WORKOUT_STRAIN", "scores"],
// RMSSD HRV joins the derived cardiac surface alongside the SDNN variant.
["HRV_RMSSD", "cardiovascular"],
// Sleep-quality indices + recommended sleep need sit in the sleep cluster.
["SLEEP_PERFORMANCE", "sleep"],
["SLEEP_EFFICIENCY", "sleep"],
["SLEEP_CONSISTENCY", "sleep"],
["SLEEP_NEED", "sleep"],
// Day energy expenditure is a cumulative activity metric (kJ analogue of
// ACTIVE_ENERGY_BURNED).
["ENERGY_EXPENDITURE_KJ", "activity"],
]);
/**
+14
View File
@@ -152,6 +152,20 @@ export function getPRDirection(
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
// v1.11.0 — WHOOP-native score classes. These are device-derived daily
// composites (recovery/strain/sleep-quality indices) and bounded /
// homeostatic metrics, not a goal axis: a higher day-strain is neither
// good nor bad, sleep-need is a recommendation rather than an achievement,
// and RMSSD HRV is one nightly composite — they stay null like the other
// derived scores rather than minting a duplicate HRV "record".
case "HRV_RMSSD":
case "DAY_STRAIN":
case "WORKOUT_STRAIN":
case "SLEEP_PERFORMANCE":
case "SLEEP_EFFICIENCY":
case "SLEEP_CONSISTENCY":
case "SLEEP_NEED":
case "ENERGY_EXPENDITURE_KJ":
return null;
}
}
@@ -0,0 +1,121 @@
/**
* v1.11.0 — cross-source priority verification when WHOOP and Apple Health
* (and the COMPUTED recovery engine) populate the same metric for the same
* day. The E-slice oracle: WHOOP feeds the existing two-axis picker via the
* v1.11 ladder additions — no new selection engine.
*
* The recommended defaults (see `src/lib/validations/source-priority.ts`)
* lead the recovery-input ladders with WHOOP (a worn-all-night strap has
* higher-resolution overnight sampling than the iPhone-relayed HealthKit
* summary), keep a real scale ahead of WHOOP's body-measurement estimate for
* weight, and rank WHOOP's device-native Recovery above the COMPUTED proxy.
*/
import { describe, expect, it } from "vitest";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
function isoDayKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
describe("cross-source priority — WHOOP + Apple Health", () => {
it("picks WHOOP over APPLE_HEALTH for resting heart rate (default)", () => {
const rows = [
{
measuredAt: new Date("2026-06-03T06:00:00Z"),
source: "APPLE_HEALTH" as const,
type: "RESTING_HEART_RATE" as const,
value: 54,
},
{
measuredAt: new Date("2026-06-03T05:30:00Z"),
source: "WHOOP" as const,
type: "RESTING_HEART_RATE" as const,
value: 51,
},
];
const out = pickCanonicalSourceRows(
rows,
"restingHeartRate",
null,
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("WHOOP");
expect(out.canonicalRows[0].value).toBe(51);
expect(out.pickedByDay.get("2026-06-03")).toBe("WHOOP");
});
it("picks WHOOP over APPLE_HEALTH for the recovery-input ladders", () => {
// sleep / hrv / respiratoryRate all lead with WHOOP in the default
// ladder. One assertion per metric guards against a future ladder
// tweak that diverges one of them.
for (const { metricKey, type } of [
{ metricKey: "hrv", type: "HEART_RATE_VARIABILITY" },
{ metricKey: "respiratoryRate", type: "RESPIRATORY_RATE" },
] as const) {
const rows = [
{
measuredAt: new Date("2026-06-03T06:00:00Z"),
source: "APPLE_HEALTH" as const,
type,
value: 42,
},
{
measuredAt: new Date("2026-06-03T05:30:00Z"),
source: "WHOOP" as const,
type,
value: 58,
},
];
const out = pickCanonicalSourceRows(rows, metricKey, null, isoDayKey);
expect(out.canonicalRows, metricKey).toHaveLength(1);
expect(out.canonicalRows[0].source, metricKey).toBe("WHOOP");
}
});
it("keeps a real scale ahead of WHOOP for weight", () => {
const rows = [
{
measuredAt: new Date("2026-06-03T07:00:00Z"),
source: "WHOOP" as const,
type: "WEIGHT" as const,
value: 80.5,
},
{
measuredAt: new Date("2026-06-03T07:05:00Z"),
source: "WITHINGS" as const,
type: "WEIGHT" as const,
value: 79.9,
},
];
const out = pickCanonicalSourceRows(rows, "weight", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("WITHINGS");
});
it("ranks WHOOP native recovery above the COMPUTED proxy", () => {
// Native-vs-derived: both rows share the RECOVERY_SCORE type and the
// same day, distinguished only by source. The `recovery` ladder
// (["WHOOP", "COMPUTED"]) resolves native-above-proxy with the same
// picker — no second engine.
const rows = [
{
measuredAt: new Date("2026-06-03T05:30:00Z"),
source: "COMPUTED" as const,
type: "RECOVERY_SCORE" as const,
value: 62,
},
{
measuredAt: new Date("2026-06-03T05:30:00Z"),
source: "WHOOP" as const,
type: "RECOVERY_SCORE" as const,
value: 71,
},
];
const out = pickCanonicalSourceRows(rows, "recovery", null, isoDayKey);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("WHOOP");
expect(out.canonicalRows[0].value).toBe(71);
});
});
@@ -89,6 +89,11 @@ export interface WorkoutPickerRow {
*/
export const DEFAULT_WORKOUT_SOURCE_PRIORITY: readonly MeasurementSource[] = [
"APPLE_HEALTH",
// v1.11.0 — WHOOP ranks second. Apple Watch GPS + HR is the richest run
// record; a WHOOP strap is the next-best when no watch logged the same
// session. The read-time picker clusters a WHOOP run and an Apple-Health
// run by (activityType, startedAt ± window) and keeps the ladder winner.
"WHOOP",
"WITHINGS",
"MANUAL",
"IMPORT",
@@ -188,22 +188,41 @@ describe("DEFAULT_SOURCE_PRIORITY", () => {
}
});
it("places APPLE_HEALTH first for cumulative metrics + HRV/sleep/RHR", () => {
it("places APPLE_HEALTH first for cumulative metrics", () => {
// HealthKit aggregates ScanWatch + iPhone sensors into a single
// canonical stream and has higher resolution than Withings' nightly
// summary for sleep stages / HRV / RHR.
// summary for the cumulative activity metrics.
for (const key of [
"steps",
"activeEnergy",
"walkingRunningDistance",
"flightsClimbed",
"sleep",
"hrv",
"restingHeartRate",
] as const) {
expect(DEFAULT_SOURCE_PRIORITY[key][0]).toBe("APPLE_HEALTH");
}
});
it("places WHOOP first for the recovery-input ladders (v1.11)", () => {
// v1.11.0 — a worn-all-night WHOOP strap has higher-resolution
// overnight sampling than the iPhone-relayed HealthKit summary or the
// Withings nightly summary for sleep / HRV / RHR / respiratory rate;
// it leads those ladders ahead of APPLE_HEALTH.
for (const key of [
"sleep",
"hrv",
"restingHeartRate",
"respiratoryRate",
] as const) {
expect(DEFAULT_SOURCE_PRIORITY[key][0]).toBe("WHOOP");
}
});
it("ranks WHOOP native recovery above the COMPUTED proxy (v1.11)", () => {
// v1.11.0 — native-vs-derived: the device-native Recovery outranks
// HealthLog's COMPUTED proxy when both exist, with the proxy as the
// fallback for users without a strap.
expect(DEFAULT_SOURCE_PRIORITY.recovery).toEqual(["WHOOP", "COMPUTED"]);
});
});
describe("DEFAULT_DEVICE_TYPE_PRIORITY", () => {
+53
View File
@@ -76,6 +76,20 @@ export const measurementTypeEnum = z.enum([
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
// ── v1.11.0 — WHOOP-native score classes (additive) ──
// Native WHOOP scores ingest server-side as `source = WHOOP`. DAY_STRAIN /
// HRV_RMSSD are deliberately distinct from STRAIN_SCORE / SDNN
// HEART_RATE_VARIABILITY so a device-native value never shares a bucket
// with a derived proxy. See `apple-health-mapping.ts` convention block and
// `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2.
"HRV_RMSSD",
"DAY_STRAIN",
"WORKOUT_STRAIN",
"SLEEP_PERFORMANCE",
"SLEEP_EFFICIENCY",
"SLEEP_CONSISTENCY",
"SLEEP_NEED",
"ENERGY_EXPENDITURE_KJ",
]);
/**
@@ -111,6 +125,10 @@ export const measurementSourceEnum = z.enum([
// the rows it surfaces; the client-facing write surfaces reject it — see
// `WRITABLE_MEASUREMENT_SOURCES` and the batch route's `batchSourceEnum`.
"COMPUTED",
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side (no
// client write path). Part of this enum so the read/response shapes (and
// the iOS decoder) can decode the rows it surfaces.
"WHOOP",
]);
/**
@@ -243,6 +261,24 @@ const unitMap: Record<string, string> = {
RECOVERY_SCORE: "score",
STRESS_SCORE: "score",
STRAIN_SCORE: "score",
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV is in milliseconds, same canonical unit as the SDNN
// HEART_RATE_VARIABILITY (different estimator, same dimension).
HRV_RMSSD: "ms",
// Day / workout strain ride WHOOP's bounded 0–21 scale; the bare "score"
// unit reads sensibly wherever the value surfaces (distinct from the
// 0–100 COMPUTED STRAIN_SCORE).
DAY_STRAIN: "score",
WORKOUT_STRAIN: "score",
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: "%",
SLEEP_EFFICIENCY: "%",
SLEEP_CONSISTENCY: "%",
// Recommended sleep duration in minutes (WHOOP reports ms; mapper ÷60000).
SLEEP_NEED: "minutes",
// Day energy expenditure in kilojoules (WHOOP-native; kept in kJ so the
// device value round-trips rather than being converted to kcal).
ENERGY_EXPENDITURE_KJ: "kJ",
};
export function getUnitForType(type: string): string {
@@ -392,6 +428,23 @@ const VALUE_RANGES: Record<string, { min: number; max: number }> = {
RECOVERY_SCORE: { min: 0, max: 100 },
STRESS_SCORE: { min: 0, max: 100 },
STRAIN_SCORE: { min: 0, max: 100 },
// ── v1.11.0 — WHOOP-native score classes ──
// RMSSD HRV (ms). Same plausibility band as the SDNN variant: lows reach
// single digits in stressed samples, 200 ms is a generous upper bound for
// relaxed athletic windows.
HRV_RMSSD: { min: 1, max: 200 },
// Day / workout strain on WHOOP's bounded 0–21 scale.
DAY_STRAIN: { min: 0, max: 21 },
WORKOUT_STRAIN: { min: 0, max: 21 },
// Sleep quality percentages (0–100).
SLEEP_PERFORMANCE: { min: 0, max: 100 },
SLEEP_EFFICIENCY: { min: 0, max: 100 },
SLEEP_CONSISTENCY: { min: 0, max: 100 },
// Recommended sleep duration in minutes — 0..1440 covers the 24-hour day.
SLEEP_NEED: { min: 0, max: 1440 },
// Day energy expenditure in kJ — 50 000 kJ (~12 000 kcal) is a generous
// ceiling over any plausible ultra-endurance day.
ENERGY_EXPENDITURE_KJ: { min: 0, max: 50000 },
};
export function validateMeasurementRange(
+29 -5
View File
@@ -122,6 +122,17 @@ export const SOURCE_PRIORITY_METRIC_KEYS = [
"hrv",
"restingHeartRate",
"vo2Max",
// v1.11.0 — WHOOP-overlapping metric classes that had no key before. The
// E-slice cross-source picker resolves WHOOP vs Apple vs Withings for
// these the same way it does the existing keys. (`sleep`, `hrv`, `spo2`,
// `restingHeartRate`, `weight` already exist.)
"skinTemperature",
"respiratoryRate",
// v1.11.0 — native-vs-derived recovery. WHOOP ships a device-native
// Recovery; HealthLog computes its own COMPUTED proxy. Both persist as
// `RECOVERY_SCORE` rows distinguished by source — this ladder lets the
// same picker resolve native-above-proxy without a second engine.
"recovery",
] as const;
export type SourcePriorityMetricKey =
@@ -207,16 +218,29 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
activeEnergy: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
walkingRunningDistance: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
sleep: ["APPLE_HEALTH", "WITHINGS"],
hrv: ["APPLE_HEALTH", "WITHINGS"],
restingHeartRate: ["APPLE_HEALTH", "WITHINGS"],
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
// v1.11.0 — WHOOP leads the recovery-input ladders (sleep / HRV / RHR):
// a worn-all-night strap has higher-resolution overnight sampling than
// the iPhone-relayed HealthKit summary or the Withings nightly summary.
sleep: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
hrv: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
restingHeartRate: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
// A real scale beats a strap's body-measurement estimate for weight.
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP"],
bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
pulse: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
spo2: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
// Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second.
spo2: ["WITHINGS", "WHOOP", "APPLE_HEALTH", "MANUAL"],
vo2Max: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
// v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the
// primary skin-temperature sensor; WHOOP's strap is second.
skinTemperature: ["WITHINGS", "WHOOP", "APPLE_HEALTH"],
respiratoryRate: ["WHOOP", "APPLE_HEALTH", "WITHINGS"],
// v1.11.0 — native-vs-derived recovery. WHOOP's device-native Recovery
// outranks HealthLog's COMPUTED proxy when both exist; the proxy is the
// fallback for users without a strap.
recovery: ["WHOOP", "COMPUTED"],
};
/**