mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user