Files
HealthLog/src/lib/fitbit/sync-metrics.ts
T
Marc-André Bombeck c5d2053e5a fix(hrv): file Fitbit and Google Health RMSSD as HRV_RMSSD
Fitbit's `dailyRmssd` and Google Health's daily HRV average are both
RMSSD, but the mappers wrote them into HEART_RATE_VARIABILITY, the SDNN
series. The `hrv` ladder ranks both above Apple Health, so an account
with an Apple Watch and a Fitbit had a series that switched estimator
from day to day. The mappers now write HRV_RMSSD, and the `hrvRmssd`
ladder carries both sources where they sat on the SDNN one.

Migration 0420 moves the rows already written (tombstones included,
with a sync-version bump so the change reaches the phone), drops their
SDNN rollup buckets and the RMSSD buckets of every account whose rows
moved, so the boot-time rollup discovery folds those accounts again,
and deletes the SDNN personal records those rows had set. One DO
block, so the move and its cleanup land together.
2026-10-21 12:00:20 +02:00

148 lines
4.9 KiB
TypeScript

/**
* Fitbit Web API health-metrics sync.
*
* Reads the launch metric endpoints from the classic Fitbit Web API and upserts
* each mapped reading as `source = FITBIT`:
*
* - body/log/weight → WEIGHT (kg)
* - body/log/fat → BODY_FAT (%)
* - spo2 summary → OXYGEN_SATURATION (%)
* - hrv summary → HRV_RMSSD (ms, dailyRmssd)
* - activities/heart → RESTING_HEART_RATE (bpm)
* - br summary → RESPIRATORY_RATE (breaths/min)
*
* Each endpoint is a date-RANGE call (one request per ≤30-day window), so the
* sync chunks `[start, end]` into per-endpoint windows to respect the API range
* caps and the 150 req/h budget. Each metric is fetched independently so a
* per-endpoint 403 (a scope the user did not grant) soft-skips only that metric.
*
* NOTE: intraday heart rate (`PULSE`) is intentionally NOT synced — the classic
* API gates intraday series behind a Personal-app / explicit per-app grant most
* self-hosters do not have, and the daily resting HR is always available. Skin
* temperature is also skipped: the classic reading is a baseline DELTA, not an
* absolute value, so it has no honest canonical slot (see mapping.md).
*/
import {
FITBIT_RANGE_DAYS,
fetchBodyFatRange,
fetchHrvRange,
fetchRespiratoryRateRange,
fetchRestingHeartRateRange,
fetchSpo2Range,
fetchWeightRange,
mapBodyFat,
mapHeartRateVariability,
mapOxygenSaturation,
mapRespiratoryRate,
mapRestingHeartRate,
mapWeight,
} from "./client";
import type { FitbitMappedMeasurement } from "./client";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { localTodayAsDate } from "./sync-core";
import {
chunkDateRanges,
getValidToken,
handleCollectionFetchError,
upsertFitbitMeasurements,
} from "./sync-core";
import type {
FitbitMeasurementUpsert,
FitbitResourceSyncOptions,
} from "./sync-core";
/** One mappable metric: a range fetcher + a body mapper + a verb. */
interface MetricResource {
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
map: (body: unknown, tz: string) => FitbitMappedMeasurement[];
verb: string;
}
const METRIC_RESOURCES: MetricResource[] = [
{ fetch: fetchWeightRange, map: mapWeight, verb: "fetchWeight" },
{ fetch: fetchBodyFatRange, map: mapBodyFat, verb: "fetchBodyFat" },
{ fetch: fetchSpo2Range, map: mapOxygenSaturation, verb: "fetchSpo2" },
{ fetch: fetchHrvRange, map: mapHeartRateVariability, verb: "fetchHrv" },
{
fetch: fetchRestingHeartRateRange,
map: mapRestingHeartRate,
verb: "fetchRhr",
},
{
fetch: fetchRespiratoryRateRange,
map: mapRespiratoryRate,
verb: "fetchRespiratoryRate",
},
];
export async function syncUserMetrics(
userId: string,
opts: FitbitResourceSyncOptions = {},
): Promise<number> {
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
// Daily rows are anchored at local noon of their date in the user's zone,
// and the window runs through the user's own today (a UTC "now" is still
// yesterday for most of a morning in a zone ahead of UTC).
const tz = await resolveUserTimezone(userId);
const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const end = opts.end ?? localTodayAsDate(tz);
const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS);
let imported = 0;
// Each metric is fetched + mapped independently so a per-endpoint 403
// soft-skips only that metric. A 403 short-circuits THAT metric (skips its
// remaining windows) without aborting the others.
for (const resource of METRIC_RESOURCES) {
const readings: FitbitMeasurementUpsert[] = [];
let forbidden = false;
for (const w of windows) {
let body: unknown;
try {
body = await resource.fetch(tokenInfo.accessToken, w.start, w.end);
} catch (err) {
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
try {
for (const m of resource.map(body, tz)) {
readings.push({
type: m.type,
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
externalId: m.fieldTag,
});
}
} catch (err) {
// A malformed point whose mapper throws routes through the same ledger
// as a fetch failure — record it, short-circuit this metric, and let the
// sibling metrics keep syncing.
imported += await handleCollectionFetchError(
resource.verb,
userId,
err,
);
forbidden = true;
break;
}
}
if (forbidden) continue;
imported += (
await upsertFitbitMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
})
).imported;
}
// `markSynced` is owned by the orchestrator (`syncUserFitbit`).
return imported;
}