/** * Fitbit / Google Health activity-bundle sync (v1.12.0, W5). * * Reads the daily cumulative-activity data types from the * `activity_and_fitness.readonly` Restricted bundle and upserts each mapped * daily total as `source = FITBIT`: * * - steps → ACTIVITY_STEPS (count) * - distance → WALKING_RUNNING_DISTANCE (metres) * - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only) * - floors → FLIGHTS_CLIMBED (count) * - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) * * These are per-day summaries (one value per calendar day). The externalId is * minted with the `stats:` daily-total prefix — `stats::` * — so a re-fetched day OVERWRITES the existing row rather than minting a * duplicate, matching the Apple-Health `stats::` overwrite * contract. A day of rest legitimately records 0 steps / 0 floors / 0 active * kcal, so the cumulative mappers preserve a zero (dropping it would leave a * hole the chart misreads as missing data); VO2 max stays strictly positive. * * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection * connected) — the six Restricted bundles are granted independently. */ import { FITBIT_DATA_TYPES, type FitbitDataType, type FitbitMappedMeasurement, fetchDataPoints, mapActiveCalories, mapDistance, mapFloors, mapSteps, mapVo2Max, } from "./client"; import { getValidToken, handleCollectionFetchError, upsertFitbitMeasurements, type FitbitMeasurementUpsert, type FitbitResourceSyncOptions, } from "./sync"; import { annotate } from "@/lib/logging/context"; /** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */ interface ActivityResource { dataType: FitbitDataType; map: (point: Record) => FitbitMappedMeasurement[]; verb: string; } const ACTIVITY_RESOURCES: ActivityResource[] = [ { dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" }, { dataType: FITBIT_DATA_TYPES.distance, map: mapDistance, verb: "fetchDistance", }, { dataType: FITBIT_DATA_TYPES.activeCalories, map: mapActiveCalories, verb: "fetchActiveCalories", }, { dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" }, { dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" }, ]; /** * Assemble the stored externalId for a mapped activity reading. A cumulative * daily metric carries the `cumulativeDaily` flag from its mapper — its * externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites * in place (the mapper's `fieldTag` is already the `:` form). */ function externalIdFor(m: FitbitMappedMeasurement): string { return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag; } export async function syncUserActivity( userId: string, opts: FitbitResourceSyncOptions = {}, ): Promise { const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; // Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a // full/backfill run. const start = opts.start; let imported = 0; for (const resource of ACTIVITY_RESOURCES) { let points: Record[]; try { points = await fetchDataPoints( resource.dataType, tokenInfo.accessToken, resource.verb, { start }, ); } catch (err) { imported += await handleCollectionFetchError(resource.verb, userId, err); continue; } const readings: FitbitMeasurementUpsert[] = []; for (const point of points) { for (const m of resource.map(point)) { readings.push({ type: m.type, value: m.value, unit: m.unit, measuredAt: m.measuredAt, externalId: externalIdFor(m), }); } } 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; }