mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
155 lines
5.1 KiB
TypeScript
155 lines
5.1 KiB
TypeScript
/**
|
|
* Fitbit Web API activity-bundle sync.
|
|
*
|
|
* Reads the daily cumulative-activity time series + VO2 max from the classic
|
|
* Fitbit Web API and upserts each mapped daily total as `source = FITBIT`:
|
|
*
|
|
* - activities/steps → ACTIVITY_STEPS (count)
|
|
* - activities/distance → WALKING_RUNNING_DISTANCE (metres, km→m)
|
|
* - activities/activityCalories → ACTIVE_ENERGY_BURNED (kcal, ACTIVE only)
|
|
* - activities/floors → FLIGHTS_CLIMBED (count)
|
|
* - cardioscore → VO2_MAX (mL/(kg·min))
|
|
*
|
|
* These are per-day summaries (one value per calendar day). The externalId is
|
|
* minted with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>`
|
|
* — so a re-fetched day OVERWRITES the existing row, matching the Apple-Health
|
|
* `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A rest day records a real 0
|
|
* (dropping it would leave a chart gap); VO2 max stays strictly positive.
|
|
*
|
|
* Each endpoint is a date-RANGE call chunked into ≤30-day windows. A per-endpoint
|
|
* 403 soft-skips THAT metric only.
|
|
*/
|
|
import {
|
|
FITBIT_RANGE_DAYS,
|
|
fetchActivitySeries,
|
|
fetchVo2MaxRange,
|
|
mapActiveCalories,
|
|
mapDistance,
|
|
mapFloors,
|
|
mapSteps,
|
|
mapVo2Max,
|
|
} 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";
|
|
import { annotate } from "@/lib/logging/context";
|
|
|
|
/** One mappable activity metric: a range fetcher + a body mapper + a verb. */
|
|
interface ActivityResource {
|
|
fetch: (accessToken: string, start: Date, end: Date) => Promise<unknown>;
|
|
map: (body: unknown, tz: string) => FitbitMappedMeasurement[];
|
|
verb: string;
|
|
}
|
|
|
|
const ACTIVITY_RESOURCES: ActivityResource[] = [
|
|
{
|
|
fetch: (t, s, e) => fetchActivitySeries("steps", t, s, e),
|
|
map: mapSteps,
|
|
verb: "fetchSteps",
|
|
},
|
|
{
|
|
fetch: (t, s, e) => fetchActivitySeries("distance", t, s, e),
|
|
map: mapDistance,
|
|
verb: "fetchDistance",
|
|
},
|
|
{
|
|
fetch: (t, s, e) => fetchActivitySeries("activityCalories", t, s, e),
|
|
map: mapActiveCalories,
|
|
verb: "fetchActiveCalories",
|
|
},
|
|
{
|
|
fetch: (t, s, e) => fetchActivitySeries("floors", t, s, e),
|
|
map: mapFloors,
|
|
verb: "fetchFloors",
|
|
},
|
|
{ fetch: fetchVo2MaxRange, map: mapVo2Max, verb: "fetchVo2Max" },
|
|
];
|
|
|
|
/**
|
|
* Assemble the stored externalId for a mapped activity reading. A cumulative
|
|
* daily metric carries the `cumulativeDaily` flag — its externalId gets the
|
|
* `stats:` daily-total prefix so a re-fetched day overwrites in place (the
|
|
* mapper's `fieldTag` is already the `<tag>:<YYYY-MM-DD>` form).
|
|
*/
|
|
function externalIdFor(m: FitbitMappedMeasurement): string {
|
|
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
|
|
}
|
|
|
|
export async function syncUserActivity(
|
|
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;
|
|
for (const resource of ACTIVITY_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: externalIdFor(m),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// A malformed point whose mapper throws routes through the same ledger
|
|
// as a fetch failure — record it, short-circuit this resource, and let
|
|
// the sibling resources 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`).
|
|
annotate({ action: { name: "fitbit.activity.sync", details: { imported } } });
|
|
return imported;
|
|
}
|