mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user