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.
156 lines
5.3 KiB
TypeScript
156 lines
5.3 KiB
TypeScript
/**
|
|
* Google Health exercise-session sync (v1.27.0).
|
|
*
|
|
* Reads exercise sessions from the `activity_and_fitness.readonly` Restricted
|
|
* bundle and upserts each into the `Workout` table as `source = GOOGLE_HEALTH`,
|
|
* keyed on `(userId, source, externalId)` so a re-fetch overwrites in place.
|
|
* Insert and update share one payload object, so a full sync repairs rows an
|
|
* older build wrote — including the raw activity type, which earlier releases
|
|
* discarded.
|
|
*
|
|
* A Google Health run and the same run via Apple Health (or WHOOP) stay distinct
|
|
* `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows`
|
|
* picker collapses the cross-source twin at read time per the user's source
|
|
* ladder. There is no ingest-time collapse for a server-owned source pair.
|
|
*
|
|
* A per-data-class 403 soft-skips the resource — the activity/fitness bundle is
|
|
* granted independently in the Google consent flow.
|
|
*/
|
|
import {
|
|
GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE,
|
|
fetchDataPoints,
|
|
} from "./data-points";
|
|
import {
|
|
GOOGLE_HEALTH_DATA_TYPES,
|
|
mapWorkout,
|
|
type GoogleHealthMappedWorkout,
|
|
} from "./mappers";
|
|
import {
|
|
getValidToken,
|
|
googleHealthTokenSource,
|
|
handleCollectionFetchError,
|
|
noteHardFailure,
|
|
runGoogleHealthCollection,
|
|
type GoogleHealthResourceSyncOptions,
|
|
} from "./sync-core";
|
|
import { prisma } from "@/lib/db";
|
|
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
|
|
export async function syncUserWorkout(
|
|
userId: string,
|
|
opts: GoogleHealthResourceSyncOptions = {},
|
|
): Promise<number> {
|
|
return runGoogleHealthCollection("fetchExercise", () =>
|
|
syncExerciseSessions(userId, opts),
|
|
);
|
|
}
|
|
|
|
async function syncExerciseSessions(
|
|
userId: string,
|
|
opts: GoogleHealthResourceSyncOptions,
|
|
): Promise<number> {
|
|
const tokenInfo = await getValidToken(userId);
|
|
if (!tokenInfo) return 0;
|
|
|
|
// Session start/end can arrive offset-less; anchor them against the user's
|
|
// stored zone rather than the process zone. The zone also shapes the
|
|
// incremental filter: exercise sessions filter on
|
|
// `exercise.interval.civil_start_time` with an offset-less civil bound, which
|
|
// must be the watermark's wall clock in the USER'S zone.
|
|
const tz = await resolveUserTimezone(userId);
|
|
|
|
// Cycle-wide watermark snapshotted once by `syncUserGoogleHealth`; undefined
|
|
// on a full/backfill run.
|
|
const start = opts.start;
|
|
|
|
let points: Record<string, unknown>[];
|
|
try {
|
|
points = await fetchDataPoints(
|
|
GOOGLE_HEALTH_DATA_TYPES.exercise,
|
|
googleHealthTokenSource(userId, tokenInfo),
|
|
"fetchExercise",
|
|
{ start, pageSize: GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE, tz },
|
|
);
|
|
} catch (err) {
|
|
return handleCollectionFetchError("fetchExercise", userId, err);
|
|
}
|
|
|
|
let imported = 0;
|
|
for (const point of points) {
|
|
const w: GoogleHealthMappedWorkout | null = mapWorkout(point, tz);
|
|
if (!w) continue; // no usable time span — not a workout
|
|
|
|
const data = {
|
|
sportType: w.sportType,
|
|
startedAt: w.startedAt,
|
|
endedAt: w.endedAt,
|
|
durationSec: w.durationSec,
|
|
totalEnergyKcal: w.totalEnergyKcal,
|
|
totalDistanceM: w.totalDistanceM,
|
|
avgHeartRate: w.avgHeartRate,
|
|
maxHeartRate: w.maxHeartRate,
|
|
minHeartRate: w.minHeartRate,
|
|
// Raw pre-mapping label, kept for provenance once
|
|
// `mapGoogleHealthSportType()` has resolved `sportType` to a canonical
|
|
// bucket — every unmapped `exerciseType` collapses to `"other"`, so
|
|
// without this the activity Google reported is gone after ingest. Never
|
|
// read back as the canonical `sportType`. Absent (rather than null) when
|
|
// the session carried no type, so a re-sync never clears metadata a
|
|
// previous run wrote.
|
|
...(w.sportTypeRaw
|
|
? { metadata: { googleExerciseType: w.sportTypeRaw } }
|
|
: {}),
|
|
};
|
|
try {
|
|
const [inserted] = await prisma.workout.createManyAndReturn({
|
|
data: {
|
|
userId,
|
|
source: "GOOGLE_HEALTH",
|
|
externalId: w.externalId,
|
|
...data,
|
|
},
|
|
skipDuplicates: true,
|
|
select: { id: true, startedAt: true },
|
|
});
|
|
if (inserted) {
|
|
void emitInsertedWorkoutArrival(
|
|
userId,
|
|
inserted,
|
|
"google-health",
|
|
).catch(() => {});
|
|
} else {
|
|
await prisma.workout.update({
|
|
where: {
|
|
userId_source_externalId: {
|
|
userId,
|
|
source: "GOOGLE_HEALTH",
|
|
externalId: w.externalId,
|
|
},
|
|
},
|
|
data,
|
|
});
|
|
}
|
|
imported++;
|
|
} catch (err) {
|
|
getEvent()?.addWarning(`google-health: failed to upsert workout: ${err}`);
|
|
// A fetched-but-unwritten session is gone once it leaves the overlap
|
|
// window — fail the cycle's verdict so the watermark holds and the next
|
|
// tick re-fetches it. No rethrow: sibling sessions keep upserting.
|
|
noteHardFailure("workout:upsert");
|
|
}
|
|
}
|
|
|
|
if (imported > 0) {
|
|
await invalidateUserMeasurements(userId);
|
|
}
|
|
|
|
// `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`).
|
|
annotate({
|
|
action: { name: "googleHealth.workout.sync", details: { imported } },
|
|
});
|
|
return imported;
|
|
}
|