Files
HealthLog/src/lib/google-health/sync-workout.ts
T
Marc-André Bombeck 7e54b6be81 refactor(integrations): split the provider clients into wire types, endpoints and mappers
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.
2026-10-23 13:30:10 +02:00

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;
}