mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged.
217 lines
7.8 KiB
TypeScript
217 lines
7.8 KiB
TypeScript
/**
|
|
* Google Health activity-bundle sync (v1.27.0).
|
|
*
|
|
* Reads the daily cumulative-activity data types from the
|
|
* `activity_and_fitness.readonly` Restricted bundle and upserts each mapped
|
|
* daily total as `source = GOOGLE_HEALTH`:
|
|
*
|
|
* - steps → ACTIVITY_STEPS (countSum)
|
|
* - distance → WALKING_RUNNING_DISTANCE (millimetersSum → m)
|
|
* - active-energy-burned → ACTIVE_ENERGY_BURNED (kcalSum — ACTIVE portion only)
|
|
* - floors → FLIGHTS_CLIMBED (countSum)
|
|
* - daily-vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins)
|
|
*
|
|
* The four cumulative types read through `POST :dailyRollUp` with
|
|
* `windowSizeDays: 1` (their `list` surface returns minute-grain buckets, not
|
|
* daily totals — and floors has no list method at all); VO2 max is a daily
|
|
* summary read through list with a `.date` filter. The externalId is minted
|
|
* with the `stats:` daily-total prefix — `stats:<fieldTag>:<YYYY-MM-DD>` — so a
|
|
* re-fetched day OVERWRITES the existing row rather than minting a duplicate,
|
|
* matching the Apple-Health `stats:<HK>:<YYYY-MM-DD>` overwrite contract. A day
|
|
* of rest legitimately records 0 steps / 0 floors / 0 active kcal, so the
|
|
* rollup mapper preserves a zero; VO2 max stays strictly positive.
|
|
*
|
|
* A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection
|
|
* connected) — the Restricted bundles are granted independently.
|
|
*/
|
|
import { fetchDailyRollUp } from "./client";
|
|
import { mapVo2Max } from "./mappers";
|
|
import { fetchDataPoints } from "./data-points";
|
|
import {
|
|
GOOGLE_HEALTH_DATA_TYPES,
|
|
type GoogleHealthDataType,
|
|
type GoogleHealthMappedMeasurement,
|
|
mapActiveEnergy,
|
|
mapDistance,
|
|
mapFloors,
|
|
mapSteps,
|
|
} from "./mappers";
|
|
import {
|
|
getValidToken,
|
|
googleHealthTokenSource,
|
|
handleCollectionFetchError,
|
|
runGoogleHealthCollection,
|
|
upsertGoogleHealthMeasurements,
|
|
type GoogleHealthMeasurementUpsert,
|
|
type GoogleHealthResourceSyncOptions,
|
|
} from "./sync-core";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import type { GoogleHealthCollectionKey } from "./collection-keys";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
/** One mappable activity metric: its data-type encoding + the mapper + a verb. */
|
|
interface ActivityResource {
|
|
dataType: GoogleHealthDataType;
|
|
map: (
|
|
point: Record<string, unknown>,
|
|
tz: string,
|
|
) => GoogleHealthMappedMeasurement[];
|
|
verb: GoogleHealthCollectionKey;
|
|
}
|
|
|
|
/** The four cumulative daily totals — read via `POST :dailyRollUp`. */
|
|
const ROLLUP_RESOURCES: ActivityResource[] = [
|
|
{
|
|
dataType: GOOGLE_HEALTH_DATA_TYPES.steps,
|
|
map: mapSteps,
|
|
verb: "fetchSteps",
|
|
},
|
|
{
|
|
dataType: GOOGLE_HEALTH_DATA_TYPES.distance,
|
|
map: mapDistance,
|
|
verb: "fetchDistance",
|
|
},
|
|
{
|
|
dataType: GOOGLE_HEALTH_DATA_TYPES.activeEnergy,
|
|
map: mapActiveEnergy,
|
|
verb: "fetchActiveEnergy",
|
|
},
|
|
{
|
|
dataType: GOOGLE_HEALTH_DATA_TYPES.floors,
|
|
map: mapFloors,
|
|
verb: "fetchFloors",
|
|
},
|
|
];
|
|
|
|
/**
|
|
* 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 `<tag>:<YYYY-MM-DD>` form).
|
|
*/
|
|
function externalIdFor(m: GoogleHealthMappedMeasurement): string {
|
|
return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag;
|
|
}
|
|
|
|
export async function syncUserActivity(
|
|
userId: string,
|
|
opts: GoogleHealthResourceSyncOptions = {},
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
const token = googleHealthTokenSource(userId, tokenInfo);
|
|
|
|
// The dailyRollUp request range is civil and user-local; resolve the user's
|
|
// stored zone so the range bounds land on the correct civil days rather than
|
|
// the process zone's. The response day-key comes from each window's own
|
|
// `civilStartTime.date`; the row is anchored at local noon of that day.
|
|
const tz = await resolveUserTimezone(userId);
|
|
|
|
// Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined
|
|
// on a full/backfill run.
|
|
const start = opts.start;
|
|
|
|
let imported = 0;
|
|
// Rollup observability — a cumulative type that "works" (HTTP 200) can still
|
|
// land zero rows for two very different reasons, and the sync log must tell
|
|
// them apart without a live-account debugging round-trip:
|
|
// emptyResponse → the service returned NO rollup points at all (Google-side
|
|
// materialisation for the account / device cohort);
|
|
// droppedAll → points arrived but none survived the mapper (a response
|
|
// naming/shape drift on this account's service revision).
|
|
const rollupEmptyResponse: string[] = [];
|
|
const rollupDroppedAll: string[] = [];
|
|
for (const resource of ROLLUP_RESOURCES) {
|
|
imported += await runGoogleHealthCollection(resource.verb, async () => {
|
|
let points: Record<string, unknown>[];
|
|
try {
|
|
points = await fetchDailyRollUp(
|
|
resource.dataType,
|
|
token,
|
|
resource.verb,
|
|
{ start, tz },
|
|
);
|
|
} catch (err) {
|
|
return handleCollectionFetchError(resource.verb, userId, err);
|
|
}
|
|
|
|
// The mapper runs INSIDE a per-type catch too (mirrors sync-metrics): a
|
|
// single malformed point whose `resource.map(point)` throws must not
|
|
// escape the ROLLUP_RESOURCES loop and abort every sibling type ordered
|
|
// after it. Route a map throw through the same ledger as a fetch failure —
|
|
// record it, fail the cycle, and move on to the next type.
|
|
const readings: GoogleHealthMeasurementUpsert[] = [];
|
|
try {
|
|
for (const point of points) {
|
|
for (const m of resource.map(point, tz)) {
|
|
readings.push({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
externalId: externalIdFor(m),
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return handleCollectionFetchError(resource.verb, userId, err);
|
|
}
|
|
if (points.length === 0) {
|
|
rollupEmptyResponse.push(resource.verb);
|
|
} else if (readings.length === 0) {
|
|
rollupDroppedAll.push(resource.verb);
|
|
}
|
|
return (
|
|
await upsertGoogleHealthMeasurements(userId, readings, {
|
|
deferRollup: opts.deferRollup,
|
|
})
|
|
).imported;
|
|
});
|
|
}
|
|
if (rollupEmptyResponse.length > 0 || rollupDroppedAll.length > 0) {
|
|
annotate({
|
|
meta: {
|
|
"googleHealth.rollup.emptyResponse": rollupEmptyResponse,
|
|
"googleHealth.rollup.droppedAll": rollupDroppedAll,
|
|
},
|
|
});
|
|
}
|
|
|
|
// VO2 max — a daily summary (list + `.date` filter), not a rollup type.
|
|
imported += await runGoogleHealthCollection("fetchVo2Max", async () => {
|
|
try {
|
|
const points = await fetchDataPoints(
|
|
GOOGLE_HEALTH_DATA_TYPES.vo2Max,
|
|
token,
|
|
"fetchVo2Max",
|
|
{ start },
|
|
);
|
|
const readings: GoogleHealthMeasurementUpsert[] = [];
|
|
for (const point of points) {
|
|
for (const m of mapVo2Max(point, tz)) {
|
|
readings.push({
|
|
type: m.type,
|
|
value: m.value,
|
|
unit: m.unit,
|
|
measuredAt: m.measuredAt,
|
|
externalId: externalIdFor(m),
|
|
});
|
|
}
|
|
}
|
|
return (
|
|
await upsertGoogleHealthMeasurements(userId, readings, {
|
|
deferRollup: opts.deferRollup,
|
|
})
|
|
).imported;
|
|
} catch (err) {
|
|
return handleCollectionFetchError("fetchVo2Max", userId, err);
|
|
}
|
|
});
|
|
|
|
// `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`).
|
|
annotate({
|
|
action: { name: "googleHealth.activity.sync", details: { imported } },
|
|
});
|
|
return imported;
|
|
}
|