From 6db3bef413bdfc2d93e46ca4896a5ae26917d50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 00:47:49 +0200 Subject: [PATCH] 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. --- messages/de.json | 19 ++- messages/en.json | 19 ++- messages/es.json | 19 ++- messages/fr.json | 19 ++- messages/it.json | 19 ++- messages/pl.json | 19 ++- .../migration.sql | 98 ++++++++++++++ prisma/schema.prisma | 94 +++++++++++++- .../measurements/measurement-list-meta.ts | 34 +++++ src/components/settings/sources-section.tsx | 8 ++ .../__tests__/i18n-locale-integrity.test.ts | 2 + .../measurement-type-enum-coverage.test.ts | 24 +++- src/lib/insights/chart-tokens.ts | 22 ++++ .../__tests__/apple-health-mapping.test.ts | 13 ++ src/lib/measurements/categories.ts | 15 +++ src/lib/personal-records/pr-direction.ts | 14 ++ .../source-priority-whoop-applehealth.test.ts | 121 ++++++++++++++++++ src/lib/sources/pick-canonical-workout.ts | 5 + .../__tests__/source-priority.test.ts | 29 ++++- src/lib/validations/measurement.ts | 53 ++++++++ src/lib/validations/source-priority.ts | 34 ++++- 21 files changed, 649 insertions(+), 31 deletions(-) create mode 100644 prisma/migrations/0111_v1110_whoop_integration/migration.sql create mode 100644 src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts diff --git a/messages/de.json b/messages/de.json index 4f310d326..4c429f993 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 09c7691fa..fb782b2f8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1959f9ad1..5941fbb4e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 8ea6667b8..3d76f5dd0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index ef0d2914b..b3b95e42a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index fd2bf24f1..a1ae9b3bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0111_v1110_whoop_integration/migration.sql b/prisma/migrations/0111_v1110_whoop_integration/migration.sql new file mode 100644 index 000000000..e86c8fcc1 --- /dev/null +++ b/prisma/migrations/0111_v1110_whoop_integration/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c87c19038..213062dff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 727a49812..ad4d1ab39 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -100,6 +100,15 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record = { 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 = { @@ -191,6 +200,19 @@ export const MEASUREMENT_TYPE_ICONS: Record = { 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 = { @@ -274,4 +296,16 @@ export const MEASUREMENT_TYPE_COLORS: Record = { 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", }; diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 8a44aa099..8b524a3bb 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -79,6 +79,10 @@ const METRIC_LABEL_KEYS: Record = { 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 = { @@ -86,6 +90,10 @@ const SOURCE_LABEL_KEYS: Record = { 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 = { diff --git a/src/lib/__tests__/i18n-locale-integrity.test.ts b/src/lib/__tests__/i18n-locale-integrity.test.ts index eac48a40a..bdda594ec 100644 --- a/src/lib/__tests__/i18n-locale-integrity.test.ts +++ b/src/lib/__tests__/i18n-locale-integrity.test.ts @@ -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 }) => { diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 60989c670..020e05f6f 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 0fb4e6df3..1518857b5 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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:` 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" diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 1a45a173a..602f1cdee 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -39,6 +39,19 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ "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", () => { diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index d7c5ac283..4377904fd 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], ]); /** diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index eedfab519..33cfb7432 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts b/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts new file mode 100644 index 000000000..d01840a97 --- /dev/null +++ b/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts @@ -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); + }); +}); diff --git a/src/lib/sources/pick-canonical-workout.ts b/src/lib/sources/pick-canonical-workout.ts index c6908f28c..7ac8aa3ca 100644 --- a/src/lib/sources/pick-canonical-workout.ts +++ b/src/lib/sources/pick-canonical-workout.ts @@ -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", diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index 766d4f2a8..9d36249f1 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -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", () => { diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 83f831067..be483d776 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { 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 = { 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( diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index e370bfc49..d10b90a38 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -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 = { 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"], }; /**