mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +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
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Atemstörung",
|
||||
"typeRecoveryScore": "Erholungs-Score",
|
||||
"typeStressScore": "Stress-Score",
|
||||
"typeStrainScore": "Belastungs-Score"
|
||||
"typeStrainScore": "Belastungs-Score",
|
||||
"typeHrvRmssd": "HRV (RMSSD)",
|
||||
"typeDayStrain": "Tagesbelastung",
|
||||
"typeWorkoutStrain": "Trainingsbelastung",
|
||||
"typeSleepPerformance": "Schlafleistung",
|
||||
"typeSleepEfficiency": "Schlafeffizienz",
|
||||
"typeSleepConsistency": "Schlafkonsistenz",
|
||||
"typeSleepNeed": "Schlafbedarf",
|
||||
"typeEnergyExpenditureKj": "Energieverbrauch"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Sauerstoffsättigung",
|
||||
"hrv": "Herzfrequenz-Variabilität",
|
||||
"restingHeartRate": "Ruhepuls",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 max",
|
||||
"skinTemperature": "Hauttemperatur",
|
||||
"respiratoryRate": "Atemfrequenz",
|
||||
"recovery": "Erholung"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manuelle Eingabe",
|
||||
"IMPORT": "Import"
|
||||
"IMPORT": "Import",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Berechnet"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Uhr",
|
||||
|
||||
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
"typeStrainScore": "Strain score",
|
||||
"typeHrvRmssd": "HRV (RMSSD)",
|
||||
"typeDayStrain": "Day strain",
|
||||
"typeWorkoutStrain": "Workout strain",
|
||||
"typeSleepPerformance": "Sleep performance",
|
||||
"typeSleepEfficiency": "Sleep efficiency",
|
||||
"typeSleepConsistency": "Sleep consistency",
|
||||
"typeSleepNeed": "Sleep need",
|
||||
"typeEnergyExpenditureKj": "Energy expenditure"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Oxygen saturation",
|
||||
"hrv": "Heart-rate variability",
|
||||
"restingHeartRate": "Resting heart rate",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 max",
|
||||
"skinTemperature": "Skin temperature",
|
||||
"respiratoryRate": "Respiratory rate",
|
||||
"recovery": "Recovery"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manual entry",
|
||||
"IMPORT": "Import"
|
||||
"IMPORT": "Import",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Computed"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
|
||||
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
"typeStrainScore": "Strain score",
|
||||
"typeHrvRmssd": "VFC (RMSSD)",
|
||||
"typeDayStrain": "Esfuerzo del día",
|
||||
"typeWorkoutStrain": "Esfuerzo del entrenamiento",
|
||||
"typeSleepPerformance": "Rendimiento del sueño",
|
||||
"typeSleepEfficiency": "Eficiencia del sueño",
|
||||
"typeSleepConsistency": "Consistencia del sueño",
|
||||
"typeSleepNeed": "Necesidad de sueño",
|
||||
"typeEnergyExpenditureKj": "Gasto energético"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Saturación de oxígeno",
|
||||
"hrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"restingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"vo2Max": "VO2 máx."
|
||||
"vo2Max": "VO2 máx.",
|
||||
"skinTemperature": "Temperatura de la piel",
|
||||
"respiratoryRate": "Frecuencia respiratoria",
|
||||
"recovery": "Recuperación"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Entrada manual",
|
||||
"IMPORT": "Importar"
|
||||
"IMPORT": "Importar",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Calculado"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Reloj",
|
||||
|
||||
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
"typeStrainScore": "Strain score",
|
||||
"typeHrvRmssd": "VFC (RMSSD)",
|
||||
"typeDayStrain": "Effort du jour",
|
||||
"typeWorkoutStrain": "Effort de la séance",
|
||||
"typeSleepPerformance": "Performance du sommeil",
|
||||
"typeSleepEfficiency": "Efficacité du sommeil",
|
||||
"typeSleepConsistency": "Régularité du sommeil",
|
||||
"typeSleepNeed": "Besoin de sommeil",
|
||||
"typeEnergyExpenditureKj": "Dépense énergétique"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Saturation en oxygène",
|
||||
"hrv": "Variabilité de la fréquence cardiaque",
|
||||
"restingHeartRate": "Fréquence cardiaque au repos",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 max",
|
||||
"skinTemperature": "Température cutanée",
|
||||
"respiratoryRate": "Fréquence respiratoire",
|
||||
"recovery": "Récupération"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Saisie manuelle",
|
||||
"IMPORT": "Importer"
|
||||
"IMPORT": "Importer",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Calculé"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Montre",
|
||||
|
||||
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
"typeStrainScore": "Strain score",
|
||||
"typeHrvRmssd": "HRV (RMSSD)",
|
||||
"typeDayStrain": "Sforzo giornaliero",
|
||||
"typeWorkoutStrain": "Sforzo dell'allenamento",
|
||||
"typeSleepPerformance": "Prestazione del sonno",
|
||||
"typeSleepEfficiency": "Efficienza del sonno",
|
||||
"typeSleepConsistency": "Costanza del sonno",
|
||||
"typeSleepNeed": "Fabbisogno di sonno",
|
||||
"typeEnergyExpenditureKj": "Dispendio energetico"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Saturazione di ossigeno",
|
||||
"hrv": "Variabilità della frequenza cardiaca",
|
||||
"restingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 max",
|
||||
"skinTemperature": "Temperatura cutanea",
|
||||
"respiratoryRate": "Frequenza respiratoria",
|
||||
"recovery": "Recupero"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Inserimento manuale",
|
||||
"IMPORT": "Importa"
|
||||
"IMPORT": "Importa",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Calcolato"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Orologio",
|
||||
|
||||
+16
-3
@@ -445,7 +445,15 @@
|
||||
"typeBreathingDisturbanceEvent": "Breathing disturbance",
|
||||
"typeRecoveryScore": "Recovery score",
|
||||
"typeStressScore": "Stress score",
|
||||
"typeStrainScore": "Strain score"
|
||||
"typeStrainScore": "Strain score",
|
||||
"typeHrvRmssd": "HRV (RMSSD)",
|
||||
"typeDayStrain": "Obciążenie dzienne",
|
||||
"typeWorkoutStrain": "Obciążenie treningowe",
|
||||
"typeSleepPerformance": "Wydajność snu",
|
||||
"typeSleepEfficiency": "Efektywność snu",
|
||||
"typeSleepConsistency": "Regularność snu",
|
||||
"typeSleepNeed": "Zapotrzebowanie na sen",
|
||||
"typeEnergyExpenditureKj": "Wydatek energetyczny"
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
@@ -3024,13 +3032,18 @@
|
||||
"spo2": "Saturacja tlenem",
|
||||
"hrv": "Zmienność rytmu serca",
|
||||
"restingHeartRate": "Tętno spoczynkowe",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 max",
|
||||
"skinTemperature": "Temperatura skóry",
|
||||
"respiratoryRate": "Częstość oddechów",
|
||||
"recovery": "Regeneracja"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Wpis ręczny",
|
||||
"IMPORT": "Importuj"
|
||||
"IMPORT": "Importuj",
|
||||
"WHOOP": "WHOOP",
|
||||
"COMPUTED": "Obliczone"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Zegarek",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
-- v1.11.0 — WHOOP integration (schema + data layer).
|
||||
--
|
||||
-- Purely-additive: two enum-extension batches, two new tables, and two new
|
||||
-- nullable columns on `users`. No backfill, no existing row touched.
|
||||
--
|
||||
-- 1. `measurement_type` += seven WHOOP-native score classes. WHOOP day-strain
|
||||
-- lands in the NEW `DAY_STRAIN` (NOT the existing COMPUTED `STRAIN_SCORE`,
|
||||
-- the v1.10.3 TRIMP engine) so the native value and the derived proxy
|
||||
-- never share a bucket. The RMSSD HRV gets its own `HRV_RMSSD` for the
|
||||
-- same reason (distinct estimator from the SDNN `HEART_RATE_VARIABILITY`).
|
||||
-- WHOOP-native Recovery reuses the existing `RECOVERY_SCORE`, distinguished
|
||||
-- only by `source = WHOOP` vs `COMPUTED`.
|
||||
-- 2. `measurement_source` += `WHOOP` for the server-side native ingest.
|
||||
-- 3. `whoop_connections` — per-user encrypted token row (1:1 with users),
|
||||
-- mirroring `withings_connections` plus `whoop_user_id`,
|
||||
-- `backfill_completed_at`, and `max_heart_rate`.
|
||||
-- 4. `whoop_oauth_states` — short-lived `(nonce → userId)` CSRF ledger,
|
||||
-- identical in shape to `withings_oauth_states`.
|
||||
-- 5. `users` += `whoop_client_id_encrypted` / `whoop_client_secret_encrypted`
|
||||
-- — per-user BYO-keys (the per-app authorized-user cap makes a single
|
||||
-- shared WHOOP app unworkable for a multi-operator product).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility: Postgres cannot remove an enum value, so the eight new
|
||||
-- members stay; with no rows carrying them they are inert. The two tables drop
|
||||
-- with `DROP TABLE IF EXISTS`, the two columns with `DROP COLUMN IF EXISTS`.
|
||||
|
||||
-- ── 1. measurement_type — append the seven WHOOP-native score classes ──
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'HRV_RMSSD';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'DAY_STRAIN';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WORKOUT_STRAIN';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_PERFORMANCE';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_EFFICIENCY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_CONSISTENCY';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_NEED';
|
||||
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ENERGY_EXPENDITURE_KJ';
|
||||
|
||||
-- ── 2. measurement_source — append the WHOOP server-owned source ───────
|
||||
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'WHOOP';
|
||||
|
||||
-- ── 3. whoop_connections ──────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS "whoop_connections" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"whoop_user_id" TEXT NOT NULL,
|
||||
"access_token" TEXT NOT NULL,
|
||||
"refresh_token" TEXT NOT NULL,
|
||||
"token_expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"scope" TEXT,
|
||||
"last_synced_at" TIMESTAMP(3),
|
||||
"backfill_completed_at" TIMESTAMP(3),
|
||||
"max_heart_rate" INTEGER,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "whoop_connections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "whoop_connections_user_id_key"
|
||||
ON "whoop_connections" ("user_id");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "whoop_connections"
|
||||
ADD CONSTRAINT "whoop_connections_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ── 4. whoop_oauth_states ─────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS "whoop_oauth_states" (
|
||||
"nonce" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "whoop_oauth_states_pkey" PRIMARY KEY ("nonce")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "whoop_oauth_states_expires_at_idx"
|
||||
ON "whoop_oauth_states" ("expires_at");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "whoop_oauth_states"
|
||||
ADD CONSTRAINT "whoop_oauth_states_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- ── 5. users — per-user WHOOP BYO-key columns (encrypted at app level) ─
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "whoop_client_id_encrypted" TEXT;
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "whoop_client_secret_encrypted" TEXT;
|
||||
+92
-2
@@ -59,6 +59,13 @@ model User {
|
||||
withingsClientIdEncrypted String? @map("withings_client_id_encrypted")
|
||||
withingsClientSecretEncrypted String? @map("withings_client_secret_encrypted")
|
||||
|
||||
// v1.11.0 — WHOOP OAuth credentials (per-user BYO-keys, encrypted at app
|
||||
// level). The per-app authorized-user cap makes a single shared WHOOP app
|
||||
// unworkable for a multi-operator product, so each self-hoster registers
|
||||
// their own WHOOP dev app and pastes the client id/secret into Settings.
|
||||
whoopClientIdEncrypted String? @map("whoop_client_id_encrypted")
|
||||
whoopClientSecretEncrypted String? @map("whoop_client_secret_encrypted")
|
||||
|
||||
// moodLog integration (per-user, encrypted)
|
||||
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
|
||||
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
|
||||
@@ -329,6 +336,10 @@ model User {
|
||||
// where the legacy `${user.id}:${nonce}` shape leaked the user id
|
||||
// into request logs / network captures.
|
||||
withingsOAuthStates WithingsOAuthState[]
|
||||
// v1.11.0 — WHOOP integration. Mirrors the Withings connection (1:1) +
|
||||
// OAuth-state ledger (1:N) relations.
|
||||
whoopConnection WhoopConnection?
|
||||
whoopOAuthStates WhoopOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
@@ -574,9 +585,25 @@ enum MeasurementType {
|
||||
// RECOVERY_SCORE is computed in v1.10.0; STRESS_SCORE + STRAIN_SCORE are
|
||||
// defined now so the later engines that compute them need no schema change.
|
||||
// These are NOT clinical vitals — they never appear in the doctor-report PDF.
|
||||
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment.
|
||||
RECOVERY_SCORE // Daily recovery / readiness proxy. Higher = more recovered. Blends RHR + HRV (SDNN) + sleep + respiratory-rate deviation from the personal baseline (Plews 2013 / Buchheit 2014 lineage). Descriptive, not a clinical or training-recovery assessment. v1.11.0: WHOOP ships a device-native Recovery into this SAME type, distinguished by `source = WHOOP` vs `COMPUTED` — the two unique keys keep both rows; the cross-source picker decides display.
|
||||
STRESS_SCORE // Reserved for the later strain/stress engine. Higher = more physiological stress. No engine computes it in v1.10.0.
|
||||
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0.
|
||||
STRAIN_SCORE // Reserved for the later strain/stress engine. Higher = more cardiovascular strain (workout-load lineage). No engine computes it in v1.10.0. WHOOP day-strain does NOT reuse this — it lands in DAY_STRAIN so the WHOOP-native value and HealthLog's TRIMP proxy never share a bucket.
|
||||
// ── v1.11.0 — WHOOP integration (additive) ──
|
||||
// Native WHOOP scores ingest as first-class `source = WHOOP` Measurement
|
||||
// rows. Where the concept already exists as a COMPUTED type (RECOVERY_SCORE)
|
||||
// the WHOOP row reuses that type and is distinguished by source; where it
|
||||
// would collide with a HealthLog engine (STRAIN_SCORE) or a different
|
||||
// physiological measure (SDNN HEART_RATE_VARIABILITY) it gets a NEW type so
|
||||
// the native value and the derived proxy never share a bucket. See
|
||||
// `.planning/v1.11-build/epic-A-whoop-buildspec.md` §3.2 / §5.
|
||||
HRV_RMSSD // ms — WHOOP `recovery.score.hrv_rmssd_milli`. The RMSSD HRV metric WHOOP reports; kept structurally distinct from the SDNN HEART_RATE_VARIABILITY (different estimator, not interchangeable).
|
||||
DAY_STRAIN // score (0–21) — WHOOP `cycle.score.strain`. The day's cardiovascular load on WHOOP's 0–21 scale. Distinct from the COMPUTED STRAIN_SCORE (0–100 TRIMP proxy) so the two never share a bucket.
|
||||
WORKOUT_STRAIN // score (0–21) — WHOOP `workout.score.strain`. Per-workout strain; preferentially stored in `Workout.metadata` (tied to the workout row) rather than as a free-floating Measurement, but the type exists for the rare detached case.
|
||||
SLEEP_PERFORMANCE // % (0–100) — WHOOP `sleep.score.sleep_performance_percentage`. Sleep achieved vs sleep needed.
|
||||
SLEEP_EFFICIENCY // % (0–100) — WHOOP `sleep.score.sleep_efficiency_percentage`. Time asleep vs time in bed.
|
||||
SLEEP_CONSISTENCY // % (0–100) — WHOOP `sleep.score.sleep_consistency_percentage`. Night-to-night bed/wake timing regularity.
|
||||
SLEEP_NEED // minutes — WHOOP `sleep.score.sleep_needed.baseline_milli` (+ debt / strain / nap components; ms→min). The model's recommended sleep duration for the night.
|
||||
ENERGY_EXPENDITURE_KJ // kJ — WHOOP `cycle.score.kilojoule`. Day energy expenditure in kilojoules (WHOOP reports kJ natively; kept in kJ rather than converted to kcal so the device-native value round-trips).
|
||||
|
||||
@@map("measurement_type")
|
||||
}
|
||||
@@ -633,6 +660,10 @@ enum MeasurementSource {
|
||||
// like WITHINGS / IMPORT, but it is part of the read enum so iOS can decode
|
||||
// the rows it queries / charts.
|
||||
COMPUTED
|
||||
// v1.11.0 — WHOOP integration. Native WHOOP scores ingest server-side
|
||||
// (no client write path) as `source = WHOOP` rows. iOS DTO mirrors this
|
||||
// exact spelling so the `/api/sync/changes` delta feed decodes the rows.
|
||||
WHOOP
|
||||
|
||||
@@map("measurement_source")
|
||||
}
|
||||
@@ -1722,6 +1753,65 @@ model WithingsOAuthState {
|
||||
@@map("withings_oauth_states")
|
||||
}
|
||||
|
||||
// ─── WHOOP ─────────────────────────────────────────────────
|
||||
//
|
||||
// v1.11.0 — structural copy of the Withings connection/oauth-state pair.
|
||||
// WHOOP ships solo on copy-of-Withings rails (no provider-adapter
|
||||
// abstraction yet). Tokens are encrypted at app level via `encrypt()` /
|
||||
// `decrypt()` — the column names mirror the Withings `access_token` /
|
||||
// `refresh_token` convention (not `*Encrypted`) so the rotation CLI's
|
||||
// column registry extends the same way.
|
||||
|
||||
model WhoopConnection {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
whoopUserId String @map("whoop_user_id")
|
||||
accessToken String @map("access_token") // encrypted at app level
|
||||
refreshToken String @map("refresh_token") // encrypted at app level
|
||||
tokenExpiresAt DateTime @map("token_expires_at")
|
||||
/// Space-separated OAuth scope string granted by the WHOOP authorisation
|
||||
/// flow (`offline read:recovery read:sleep read:workout read:cycles
|
||||
/// read:profile read:body_measurement`). `offline` is required to receive
|
||||
/// a refresh token. Null = legacy connection that predates scope tracking.
|
||||
scope String? @map("scope")
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
/// Set once the self-converging history backfill walks every collection to
|
||||
/// completion. Null = backfill still in flight (or never started).
|
||||
backfillCompletedAt DateTime? @map("backfill_completed_at")
|
||||
/// Max heart rate from the WHOOP body-measurement profile — a profile
|
||||
/// constant, not a time series, so it lives here rather than as a
|
||||
/// Measurement row.
|
||||
maxHeartRate Int? @map("max_heart_rate")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("whoop_connections")
|
||||
}
|
||||
|
||||
/// v1.11.0 — short-lived `(nonce → userId)` ledger backing the WHOOP OAuth
|
||||
/// state check, identical in shape + lifecycle to `WithingsOAuthState`.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// * `connect` mints a row with `expiresAt = now() + 10 min` and sets the
|
||||
/// state cookie to JUST the random base64url nonce (no user id).
|
||||
/// * `callback` looks up the row, asserts `expiresAt > now()`, then DELETEs
|
||||
/// it in the same transaction — replay of the same nonce fails because
|
||||
/// the row is gone.
|
||||
/// * A daily cleanup cron sweeps abandoned rows whose `expiresAt` passed.
|
||||
model WhoopOAuthState {
|
||||
nonce String @id
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
@@map("whoop_oauth_states")
|
||||
}
|
||||
|
||||
// ─── Mood Entries (moodLog) ───────────────────────────────────
|
||||
|
||||
model MoodEntry {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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