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

@@ -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"],
};
/**