Files
HealthLog/src/lib/fitbit/sync-metrics.ts
T
Marc-André Bombeck 34f847f12f fix(integrations): anchor synced daily totals at local noon in the user's zone
Withings, Fitbit, Google Health, Polar and Oura wrote each daily total and
daily summary at noon UTC of its date. From UTC+12 on (New Zealand all year,
Fiji, Tonga, Samoa) noon UTC is already the next local day, so every synced
step count, SpO2 or resting-pulse day showed up a day late. The rows now sit
at local noon of their date in the user's zone, and the fetch windows run
through the user's own today.

Fitbit weigh-ins carry a local wall clock without an offset and were read
as UTC; they are read in the user's zone now.

ExternalIds stay on the calendar date, so the next sync finds each row and
moves it in place instead of adding a second one, and the day a row leaves
is refolded. Rows inside each integration's re-read window heal on the next
sync; Fitbit and Google Health re-read the whole history on reconnect.
2026-09-26 15:58:02 +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 → HEART_RATE_VARIABILITY (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;
}