feat(google-health): wire the OAuth routes, sync jobs, and source priority

Add the request surface and the background sync for the Google Health
provider, all built on the shared integration plumbing.

- Routes under /api/google-health: connect (mint the PKCE pair and state
  nonce, redirect to Google), callback (timing-safe CSRF, atomic state
  consume, token exchange, connection upsert, clear the reauth flag,
  enqueue backfill), credentials (encrypt the operator's BYO client id
  and secret field-by-field), disconnect, status, and a rate-limited
  manual sync.
- Jobs: a boot-time backfill, an hourly poll cohort, and a daily
  oauth-state cleanup, registered under the existing dead-queue contract.
- Rank GOOGLE_HEALTH just after Fitbit in the per-metric source ladders;
  it is the successor API for the same devices.
- Add GOOGLE_HEALTH to the readable measurement-source enum but leave it
  out of the writable set — the provider is server-owned, so a client
  cannot attribute a write to it.
- Register the two new encrypted token columns and the two BYO-client
  columns in the encryption-rotation registry and the rotation script.
This commit is contained in:
Marc-André Bombeck committed 2026-07-03 14:13:28 +02:00
1 parent fb17a4e9c6
commit b26a18d35e
15 files changed
+1107 -10

No files matched your search

+5
View File
@@ -55,6 +55,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "User", field: "whoopClientSecretEncrypted", kind: "string" },
{ model: "User", field: "fitbitClientIdEncrypted", kind: "string" },
{ model: "User", field: "fitbitClientSecretEncrypted", kind: "string" },
// v1.27.0 — Google Health (Fitbit / Pixel Watch) BYO OAuth client.
{ model: "User", field: "googleHealthClientIdEncrypted", kind: "string" },
{ model: "User", field: "googleHealthClientSecretEncrypted", kind: "string" },
{ model: "User", field: "nightscoutUrlEncrypted", kind: "string" },
{ model: "User", field: "nightscoutTokenEncrypted", kind: "string" },
{ model: "User", field: "polarAccessTokenEncrypted", kind: "string" },
@@ -82,6 +85,8 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "WhoopConnection", field: "refreshToken", kind: "string" },
{ model: "FitbitConnection", field: "accessToken", kind: "string" },
{ model: "FitbitConnection", field: "refreshToken", kind: "string" },
{ model: "GoogleHealthConnection", field: "accessToken", kind: "string" },
{ model: "GoogleHealthConnection", field: "refreshToken", kind: "string" },
// ───── AppSettings — operator credentials ─────
{ model: "AppSettings", field: "adminAiKeyEncrypted", kind: "string" },
@@ -0,0 +1,90 @@
/**
* v1.26.0 — pg-boss queue registration for the Google Health sync layer.
*
* The reminder-worker imports heavy infrastructure (pg-boss, Prisma adapter,
* notification dispatchers, …) so we don't boot the whole worker here. Instead
* we read the source as text and assert the queue constants + cron schedules +
* handler wiring + allQueues + boot-enqueue are present — the fast guard that
* catches the v1.4.37 dead-queue class (a queue declared but never registered
* in `allQueues`, which silently never drains).
*
* Google Health is poll-only at launch (no webhook — Pub/Sub deferred), so a
* single hourly `google-health-sync` queue drives the per-user
* `syncUserGoogleHealth` driver across every connection, alongside the backfill
* + the daily OAuth-state sweep.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const REGISTRAR_PATH = join(
__dirname,
"..",
"reminder",
"register-integration-sync.ts",
);
const source =
readFileSync(REGISTRAR_PATH, "utf8") +
readFileSync(
join(__dirname, "..", "reminder", "google-health-sync.ts"),
"utf8",
);
const GOOGLE_HEALTH_QUEUE_CONSTS = [
"GOOGLE_HEALTH_SYNC_QUEUE",
"GOOGLE_HEALTH_BACKFILL_QUEUE",
"GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE",
] as const;
describe("reminder-worker — Google Health sync queues", () => {
it("declares the Google Health sync + oauth-state-cleanup queue constants", () => {
expect(source).toMatch(
/GOOGLE_HEALTH_SYNC_QUEUE\s*=\s*["']google-health-sync["']/,
);
expect(source).toMatch(
/GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE\s*=\s*["']google-health-oauth-state-cleanup["']/,
);
});
it("registers EVERY Google Health queue in allQueues (v1.4.37 dead-queue guard)", () => {
const block = /const allQueues = \[([\s\S]*?)\];/.exec(source);
expect(block).not.toBeNull();
const body = block![1]!;
for (const q of GOOGLE_HEALTH_QUEUE_CONSTS) {
expect(body).toContain(q);
}
});
it("schedules the hourly poll cron + the daily oauth-state sweep", () => {
expect(source).toMatch(
/\[GOOGLE_HEALTH_SYNC_QUEUE,\s*GOOGLE_HEALTH_SYNC_CRON\]/,
);
expect(source).toMatch(
/\[\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,\s*GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,?\s*\]/,
);
});
it("registers a boss.work handler for every Google Health queue", () => {
expect(source).toMatch(
/boss\.work[\s\S]{0,200}GOOGLE_HEALTH_SYNC_QUEUE[\s\S]{0,200}handleGoogleHealthSync/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,320}GOOGLE_HEALTH_BACKFILL_QUEUE[\s\S]{0,320}runGoogleHealthBackfillForUser/,
);
expect(source).toMatch(
/boss\.work[\s\S]{0,200}GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE[\s\S]{0,200}handleGoogleHealthOAuthStateCleanup/,
);
});
it("wires the self-converging Google Health backfill boot discovery", () => {
expect(source).toMatch(/enqueueBootTimeGoogleHealthBackfill\(\)/);
});
it("imports the Google Health sync driver + backfill exports", () => {
expect(source).toMatch(/from\s*["']@\/lib\/google-health\/sync["']/);
expect(source).toMatch(
/from\s*["']@\/lib\/jobs\/google-health-backfill["']/,
);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* v1.26.0 — pg-boss queue + boot-time self-converging backfill for newly
* connected Google Health accounts. Modelled on the Fitbit / WHOOP backfill
* (`src/lib/jobs/fitbit-backfill.ts`): a discovery query enqueues one job per
* connection that has NOT yet been backfilled, and the pass is idempotent
* across reboots (the predicate `backfill_completed_at IS NULL` drops a
* connection once its backfill finishes).
*
* The queue name MUST be registered in `allQueues` in
* `src/lib/jobs/reminder/register-integration-sync.ts` or pg-boss never
* provisions it and the boot enqueue silently never drains (the v1.4.37
* dead-queue class).
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { syncUserGoogleHealth } from "@/lib/google-health/sync";
export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill";
/**
* Serial concurrency — a backfill walks years of history for one account and is
* rate-bounded by Google's per-app quota; concurrency-1 keeps it from crowding
* the request pool, matching `FITBIT_BACKFILL_CONCURRENCY`.
*/
export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1;
export interface GoogleHealthBackfillPayload {
userId: string;
enqueuedAt: string;
}
/**
* Per-user backfill handler. Runs a full-history sync for one account and
* stamps `backfillCompletedAt` so the discovery query drops it. Idempotent: the
* per-resource upserts are key-stable, so a re-run (e.g. a reboot mid-walk)
* overwrites rather than duplicating. Mirrors `runFitbitBackfillForUser`.
*/
export async function runGoogleHealthBackfillForUser(
userId: string,
): Promise<{ imported: number }> {
const imported = await syncUserGoogleHealth(userId, { fullSync: true });
await prisma.googleHealthConnection.update({
where: { userId },
data: { backfillCompletedAt: new Date() },
});
annotate({
action: {
name: "google_health.backfill.complete",
details: { imported },
},
});
return { imported };
}
/**
* Boot-time discovery. Finds every Google Health connection not yet backfilled
* (`backfill_completed_at IS NULL`) and enqueues one backfill job per account.
*
* Idempotent across reboots: once a connection's backfill completes,
* `backfillCompletedAt` is set and the predicate drops it from the discovery
* set. pg-boss `singletonKey` coalesces duplicate sends so a fast restart while
* a job is queued doesn't double up.
*
* Best-effort: errors are returned through the result value so the worker boot
* never fails because of a backfill miss.
*/
export async function enqueueBootTimeGoogleHealthBackfill(): Promise<{
enqueued: number;
skipped: number;
error: string | null;
}> {
const boss = getGlobalBoss();
if (!boss) {
return { enqueued: 0, skipped: 0, error: null };
}
try {
const connections = await prisma.googleHealthConnection.findMany({
where: { backfillCompletedAt: null },
select: { userId: true },
});
if (connections.length === 0) {
return { enqueued: 0, skipped: 0, error: null };
}
let enqueued = 0;
let skipped = 0;
for (const { userId } of connections) {
const payload: GoogleHealthBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
const jobId = await boss.send(GOOGLE_HEALTH_BACKFILL_QUEUE, payload, {
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `google-health-backfill|${userId}`,
});
if (jobId) {
enqueued += 1;
} else {
skipped += 1;
}
}
return { enqueued, skipped, error: null };
} catch (err) {
return {
enqueued: 0,
skipped: 0,
error: err instanceof Error ? err.message : String(err),
};
}
}
@@ -0,0 +1,23 @@
/**
* v1.26.0 — daily cleanup for the `google_health_oauth_states` table.
*
* Mirrors `fitbit-oauth-state-cleanup`. The OAuth state ledger is single-use —
* every happy-path + error-path branch in `google-health/callback` consumes its
* row, so an abandoned row only lingers when the user closed the Google approval
* tab without bouncing back to the callback URL. The cookie + row TTL is 10
* minutes, so once the timestamp blows the row is dead weight.
*
* Idempotent: a re-run within the same window matches zero rows the second time
* because the first pass deleted everything older than `now()`.
*/
import type { PrismaClient } from "@/generated/prisma/client";
export async function cleanupExpiredGoogleHealthOAuthStates(
prisma: PrismaClient,
now: Date = new Date(),
): Promise<number> {
const { count } = await prisma.googleHealthOAuthState.deleteMany({
where: { expiresAt: { lt: now } },
});
return count;
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Google Health hourly poll-cohort sync and OAuth-state cleanup handlers.
*
* Extracted alongside the Fitbit handlers; register-integration-sync.ts owns the
* queue names, cron schedules, and boss.work registrations.
*/
import { type Job } from "pg-boss";
import { recordError } from "@/lib/jobs/worker-status";
import { withBackgroundEvent } from "@/lib/logging/background";
import { runGoogleHealthPollCohort } from "@/lib/google-health/sync";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup";
import { getWorkerPrisma } from "./shared";
/**
* v1.26.0 — Google Health poll-sync payload. Poll-only (no webhook — Pub/Sub is
* deferred): the single hourly cron tick carries no `userId`, so the handler
* iterates every Google Health connection and re-syncs each via
* `syncUserGoogleHealth`. One user's parked-at-reauth state never starves the
* rest of the cohort.
*/
export interface GoogleHealthSyncPayload {
userId?: string;
}
export async function handleGoogleHealthSync(
jobs: Job<GoogleHealthSyncPayload>[],
) {
await withBackgroundEvent("job.google_health_sync", async (evt) => {
const prisma = getWorkerPrisma();
try {
const targets: Array<{ userId: string }> = [];
for (const job of jobs) {
if (job.data?.userId) targets.push({ userId: job.data.userId });
}
if (targets.length === 0) {
const connections = await prisma.googleHealthConnection.findMany({
select: { userId: true },
});
targets.push(...connections);
}
if (targets.length === 0) return;
// Fan the cohort out with bounded concurrency + per-user error isolation:
// one slow Google response can't stall the whole cohort, and a single
// user's failure is warned without aborting the pass.
const { usersSynced, measurementsImported } =
await runGoogleHealthPollCohort(
targets.map((t) => t.userId),
{
onUserError: (userId, err) =>
evt.addWarning(
`job.google_health_sync failed for user ${userId}: ${err}`,
),
// A fresh reading landed; resolve the user's Vorsorge reminders
// eventfully. Fire-and-forget; the cron is the net.
onUserSynced: (userId, imported) => {
if (imported > 0) {
void enqueueReminderSatisfy(userId).catch(() => {});
}
},
},
);
evt.setBackground({
task_name: "job.google_health_sync",
result: {
users_synced: usersSynced,
total: targets.length,
measurements_imported: measurementsImported,
},
});
} catch (err) {
evt.setError(err);
recordError();
throw err;
}
});
}
export interface GoogleHealthOAuthStateCleanupPayload {
triggeredAt?: string;
}
export async function handleGoogleHealthOAuthStateCleanup(
jobs: Job<GoogleHealthOAuthStateCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent(
"job.google_health_oauth_state_cleanup",
async (evt) => {
const p = getWorkerPrisma();
try {
const deleted = await cleanupExpiredGoogleHealthOAuthStates(p);
evt.addMeta("google_health_oauth_state_cleanup_deleted", deleted);
} catch (err) {
evt.addWarning(`google-health-oauth-state-cleanup failed: ${err}`);
}
},
);
}
@@ -28,6 +28,13 @@ import {
enqueueBootTimeFitbitBackfill,
type FitbitBackfillPayload,
} from "@/lib/jobs/fitbit-backfill";
import {
GOOGLE_HEALTH_BACKFILL_QUEUE,
GOOGLE_HEALTH_BACKFILL_CONCURRENCY,
runGoogleHealthBackfillForUser,
enqueueBootTimeGoogleHealthBackfill,
type GoogleHealthBackfillPayload,
} from "@/lib/jobs/google-health-backfill";
import {
SLEEP_TIMELINE_BACKFILL_QUEUE,
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
@@ -75,6 +82,12 @@ import {
FitbitOAuthStateCleanupPayload,
handleFitbitOAuthStateCleanup,
} from "./fitbit-sync";
import {
GoogleHealthSyncPayload,
handleGoogleHealthSync,
GoogleHealthOAuthStateCleanupPayload,
handleGoogleHealthOAuthStateCleanup,
} from "./google-health-sync";
const WITHINGS_SYNC_QUEUE = "withings-fallback-sync";
@@ -150,6 +163,23 @@ const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08
const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup";
const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *";
// v1.26.0 — Google Health / Fitbit-Pixel-Wear-OS poll-only sync. There is no
// Google Health webhook at launch (Pub/Sub deferred), so a single hourly cron
// drives the per-user `syncUserGoogleHealth` driver across every connection.
// Minute staggered off the WHOOP (:05/:20/:35/:50), Fitbit (:08), Nightscout
// (:11), Polar (:13), and Oura (:15) slots so the hourly ticks don't pile up on
// one boss poll.
const GOOGLE_HEALTH_SYNC_QUEUE = "google-health-sync";
const GOOGLE_HEALTH_SYNC_CRON = "18 * * * *"; // every hour at :18
// v1.26.0 — daily sweep for the Google Health OAuth state ledger. Slots at
// 03:26, next to the Fitbit sweep (03:24), inside the maintenance window.
const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE =
"google-health-oauth-state-cleanup";
const GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON = "26 3 * * *";
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick
// pulls the recent SGV window per configured instance. :11 staggers off the
// WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't
@@ -195,6 +225,11 @@ const allQueues = [
FITBIT_SYNC_QUEUE,
FITBIT_BACKFILL_QUEUE,
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
// v1.26.0 — Google Health poll-only sync (no webhook at launch),
// self-converging boot backfill, and the daily OAuth-state ledger sweep.
GOOGLE_HEALTH_SYNC_QUEUE,
GOOGLE_HEALTH_BACKFILL_QUEUE,
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
// v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
// Discovery enqueues one job per connection whose sleep rows predate the
// stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and
@@ -230,6 +265,14 @@ const schedules: ScheduleEntry[] = [
// daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states.
[FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON],
[FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON],
// v1.26.0 — hourly Google Health poll (:18, staggered off WHOOP/Fitbit/
// Nightscout/Polar/Oura) + the daily 03:26 Europe/Berlin prune for expired
// Google Health OAuth states.
[GOOGLE_HEALTH_SYNC_QUEUE, GOOGLE_HEALTH_SYNC_CRON],
[
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_CRON,
],
// v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync
// ticks).
[NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON],
@@ -341,6 +384,36 @@ export async function registerIntegrationSyncQueues(
{ localConcurrency: 1 },
handleFitbitOAuthStateCleanup,
);
// v1.26.0 — Google Health poll-sync (cron full-iteration; no webhook). Serial
// concurrency so a backfill-heavy tick never crowds the request pool.
await boss.work<GoogleHealthSyncPayload>(
GOOGLE_HEALTH_SYNC_QUEUE,
{ localConcurrency: 1 },
handleGoogleHealthSync,
);
// v1.26.0 — self-converging Google Health backfill. The boot enqueue below
// sends one full-history sync per un-backfilled connection; this handler runs
// it and stamps `backfillCompletedAt` so the discovery query drops the
// account.
await boss.work<GoogleHealthBackfillPayload>(
GOOGLE_HEALTH_BACKFILL_QUEUE,
{ localConcurrency: GOOGLE_HEALTH_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
const { imported } = await runGoogleHealthBackfillForUser(userId);
workerLog(
"info",
`[google-health-backfill] user=${userId} imported=${imported}`,
);
}
},
);
await boss.work<GoogleHealthOAuthStateCleanupPayload>(
GOOGLE_HEALTH_OAUTH_STATE_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handleGoogleHealthOAuthStateCleanup,
);
// v1.17.1 — one-shot sleep-timeline backfill. The boot enqueue below sends
// one job per (user, provider) whose sleep rows predate the stamp/shape fix;
// this handler deletes the affected rows, re-syncs, and stamps the marker so
@@ -458,6 +531,30 @@ export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
);
}
// v1.26.0 — Google Health backfill. Finds every Google Health connection not
// yet backfilled and enqueues one full-history sync per account.
try {
const { enqueued, skipped, error } =
await enqueueBootTimeGoogleHealthBackfill();
if (error) {
workerLog(
"error",
`[google-health-backfill] boot discovery failed: ${error}`,
);
} else {
workerLog(
"info",
`[google-health-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
);
}
} catch (err) {
workerLog(
"error",
"[google-health-backfill] boot discovery threw an unexpected error",
err,
);
}
// v1.17.1 — one-shot sleep-timeline backfill. Finds every WHOOP + Withings
// connection whose sleep rows predate the stamp/shape fix and enqueues one
// job per (user, provider). A completed pass stamps `sleepTimelineBackfillAt`.
+6
View File
@@ -204,6 +204,12 @@ export const measurementSourceEnum = z.enum([
// of this enum so the read/response shapes (and the iOS decoder) can decode
// the rows it surfaces.
"MCP",
// v1.26.0 — Fitbit/Pixel + Wear OS via the Google Health API. Server-owned
// (no client write path), exactly like WHOOP/FITBIT. Part of this enum so the
// read/response shapes (and the iOS decoder) can decode the rows it surfaces;
// deliberately absent from `WRITABLE_MEASUREMENT_SOURCES` + the batch
// allowlist so a client can never forge a GOOGLE_HEALTH-attributed row.
"GOOGLE_HEALTH",
]);
/**
+84 -10
View File
@@ -234,11 +234,24 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// they rank below the phone-aggregated Apple Health (which sees all-day
// steps) but as legitimate sources above MANUAL, so an Oura/Polar-only day
// is not dropped when another source coexists.
steps: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "OURA", "POLAR", "MANUAL"],
// v1.26.0 — Google Health surfaces Fitbit/Pixel/Wear OS device data (the
// successor API to the classic Fitbit Web API); it rides in the same
// wrist-wearable class as FITBIT and ranks immediately below it in every
// device ladder, above OURA/POLAR and MANUAL.
steps: [
"APPLE_HEALTH",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
],
activeEnergy: [
"APPLE_HEALTH",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
@@ -247,11 +260,18 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"APPLE_HEALTH",
"WITHINGS",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
],
flightsClimbed: ["APPLE_HEALTH", "WITHINGS", "FITBIT", "MANUAL"],
flightsClimbed: [
"APPLE_HEALTH",
"WITHINGS",
"FITBIT",
"GOOGLE_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.
@@ -260,26 +280,64 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
// v1.17.0 — Polar + Oura are worn wearables in the same overnight-sampling
// class as WHOOP/Fitbit; they rank below the established straps but above
// the iPhone-relayed HealthKit summary and the Withings nightly summary.
sleep: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"],
hrv: ["WHOOP", "FITBIT", "OURA", "POLAR", "APPLE_HEALTH", "WITHINGS"],
sleep: [
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"WITHINGS",
],
hrv: [
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"WITHINGS",
],
restingHeartRate: [
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"WITHINGS",
],
// A real scale beats a strap's body-measurement estimate for weight.
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "WHOOP", "FITBIT"],
weight: [
"WITHINGS",
"APPLE_HEALTH",
"MANUAL",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
],
bloodPressure: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
// v1.17.0 — continuous optical-HR wearables (Fitbit / Polar / Oura) rank
// above a hand-typed MANUAL pulse; the device-source order matches the
// rhr / hrv ladders (FITBIT > OURA > POLAR). Withings BPM cuff + the
// HealthKit relay stay the primary point-measurement sources.
pulse: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "OURA", "POLAR", "MANUAL"],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"],
bodyTemperature: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT"],
pulse: [
"WITHINGS",
"APPLE_HEALTH",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"MANUAL",
],
bodyFat: ["WITHINGS", "APPLE_HEALTH", "MANUAL", "FITBIT", "GOOGLE_HEALTH"],
bodyTemperature: [
"WITHINGS",
"APPLE_HEALTH",
"MANUAL",
"FITBIT",
"GOOGLE_HEALTH",
],
// Withings ScanWatch pulse-ox is the primary SpO2 sensor; WHOOP second,
// Fitbit third, Oura's ring fourth. v1.17.1 — Polar Elixir pulse-ox slots
// below the established straps and above the iPhone-relay / manual sources.
@@ -287,19 +345,35 @@ export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
"WITHINGS",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",
"MANUAL",
],
vo2Max: ["WITHINGS", "APPLE_HEALTH", "FITBIT", "OURA", "MANUAL"],
vo2Max: [
"WITHINGS",
"APPLE_HEALTH",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"MANUAL",
],
// v1.11.0 — new WHOOP-overlapping keys. ScanWatch dermal reading is the
// primary skin-temperature sensor; WHOOP's strap is second, Fitbit third,
// Oura's ring fourth.
skinTemperature: ["WITHINGS", "WHOOP", "FITBIT", "OURA", "APPLE_HEALTH"],
skinTemperature: [
"WITHINGS",
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"APPLE_HEALTH",
],
respiratoryRate: [
"WHOOP",
"FITBIT",
"GOOGLE_HEALTH",
"OURA",
"POLAR",
"APPLE_HEALTH",